make hi-res work
This commit is contained in:
parent
c2b3f7312e
commit
6d79dafadb
7 changed files with 87 additions and 39 deletions
|
|
@ -4063,6 +4063,7 @@
|
||||||
</div>
|
</div>
|
||||||
<select id="streaming-quality-setting">
|
<select id="streaming-quality-setting">
|
||||||
<option value="auto">Auto (Adaptive)</option>
|
<option value="auto">Auto (Adaptive)</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>
|
||||||
<option value="LOW">AAC 96kbps</option>
|
<option value="LOW">AAC 96kbps</option>
|
||||||
|
|
|
||||||
16
js/api.js
16
js/api.js
|
|
@ -11,6 +11,7 @@ import { preferDolbyAtmosSettings, trackDateSettings, devModeSettings } from './
|
||||||
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';
|
||||||
|
import { getProxyUrl } from './proxy-utils.js';
|
||||||
import { loadFfmpeg, FfmpegError, ffmpeg } from './ffmpeg.js';
|
import { loadFfmpeg, FfmpegError, ffmpeg } from './ffmpeg.js';
|
||||||
import { triggerDownload, applyAudioPostProcessing } from './download-utils.ts';
|
import { triggerDownload, applyAudioPostProcessing } from './download-utils.ts';
|
||||||
import { isCustomFormat } from './ffmpegFormats.ts';
|
import { isCustomFormat } from './ffmpegFormats.ts';
|
||||||
|
|
@ -117,7 +118,9 @@ export class LosslessAPI {
|
||||||
for (let attempt = 1; attempt <= maxTotalAttempts; attempt++) {
|
for (let attempt = 1; attempt <= maxTotalAttempts; attempt++) {
|
||||||
const instance = instances[instanceIndex % instances.length];
|
const instance = instances[instanceIndex % instances.length];
|
||||||
const baseUrl = typeof instance === 'string' ? instance : instance.url;
|
const baseUrl = typeof instance === 'string' ? instance : instance.url;
|
||||||
const url = baseUrl.endsWith('/') ? `${baseUrl}${relativePath.substring(1)}` : `${baseUrl}${relativePath}`;
|
const url = baseUrl.endsWith('/')
|
||||||
|
? `${baseUrl}${relativePath.substring(1)}`
|
||||||
|
: `${baseUrl}${relativePath}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, { signal: options.signal });
|
const response = await fetch(url, { signal: options.signal });
|
||||||
|
|
@ -134,7 +137,10 @@ export class LosslessAPI {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
const errorData = await response.clone().json().catch(() => null);
|
const errorData = await response
|
||||||
|
.clone()
|
||||||
|
.json()
|
||||||
|
.catch(() => null);
|
||||||
if (errorData?.subStatus === 11002) {
|
if (errorData?.subStatus === 11002) {
|
||||||
console.warn(`Auth failed on ${baseUrl}. Trying next instance...`);
|
console.warn(`Auth failed on ${baseUrl}. Trying next instance...`);
|
||||||
instanceIndex++;
|
instanceIndex++;
|
||||||
|
|
@ -1763,7 +1769,7 @@ export class LosslessAPI {
|
||||||
if (streamUrl.startsWith('blob:')) {
|
if (streamUrl.startsWith('blob:')) {
|
||||||
try {
|
try {
|
||||||
const downloader = new DashDownloader();
|
const downloader = new DashDownloader();
|
||||||
blob = await downloader.downloadDashStream(streamUrl, {
|
blob = await downloader.downloadDashStream(getProxyUrl(streamUrl), {
|
||||||
signal: options.signal,
|
signal: options.signal,
|
||||||
onProgress,
|
onProgress,
|
||||||
calculateDashBytes: calculateDashBytes ?? true,
|
calculateDashBytes: calculateDashBytes ?? true,
|
||||||
|
|
@ -1782,7 +1788,7 @@ export class LosslessAPI {
|
||||||
} else if (streamUrl.includes('.m3u8') || streamUrl.includes('application/vnd.apple.mpegurl')) {
|
} else if (streamUrl.includes('.m3u8') || streamUrl.includes('application/vnd.apple.mpegurl')) {
|
||||||
try {
|
try {
|
||||||
const downloader = new HlsDownloader();
|
const downloader = new HlsDownloader();
|
||||||
blob = await downloader.downloadHlsStream(streamUrl, {
|
blob = await downloader.downloadHlsStream(getProxyUrl(streamUrl), {
|
||||||
signal: options.signal,
|
signal: options.signal,
|
||||||
onProgress,
|
onProgress,
|
||||||
});
|
});
|
||||||
|
|
@ -1807,7 +1813,7 @@ export class LosslessAPI {
|
||||||
/* ignore HEAD failure; proceed with GET */
|
/* ignore HEAD failure; proceed with GET */
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(streamUrl, {
|
const response = await fetch(getProxyUrl(streamUrl), {
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
signal: options.signal,
|
signal: options.signal,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
26
js/app.js
26
js/app.js
|
|
@ -467,31 +467,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
const audioPlayer = document.getElementById('audio-player');
|
const audioPlayer = document.getElementById('audio-player');
|
||||||
|
|
||||||
// i love ios and macos!!!! webkit fucking SUCKS BULLSHIT sorry ios/macos heads yall getting lossless only playback
|
// i love ios and macos!!!! webkit fucking SUCKS BULLSHIT sorry ios/macos heads yall getting lossless only playback
|
||||||
// Use isIos from platform-detection (set before UA spoof in index.html) so detection works on real iOS.
|
const currentQuality = localStorage.getItem('playback-quality') || 'HI_RES_LOSSLESS';
|
||||||
if (isIos || isSafari) {
|
|
||||||
const qualitySelect = document.getElementById('streaming-quality-setting');
|
|
||||||
const downloadQualitySelect = document.getElementById('download-quality-setting');
|
|
||||||
|
|
||||||
const removeHiRes = (select) => {
|
|
||||||
if (!select) return;
|
|
||||||
const option = select.querySelector('option[value="HI_RES_LOSSLESS"]');
|
|
||||||
if (option) option.remove();
|
|
||||||
};
|
|
||||||
|
|
||||||
removeHiRes(qualitySelect);
|
|
||||||
removeHiRes(downloadQualitySelect);
|
|
||||||
|
|
||||||
if (isIos) {
|
|
||||||
document.querySelector('#hi-res-download-warning').style.display = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentQualitySetting = localStorage.getItem('playback-quality');
|
|
||||||
if (!currentQualitySetting || currentQualitySetting === 'HI_RES_LOSSLESS') {
|
|
||||||
localStorage.setItem('playback-quality', 'LOSSLESS');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentQuality = localStorage.getItem('playback-quality') || 'LOSSLESS';
|
|
||||||
await Player.initialize(audioPlayer, MusicAPI.instance, currentQuality);
|
await Player.initialize(audioPlayer, MusicAPI.instance, currentQuality);
|
||||||
|
|
||||||
// Initialize tracker
|
// Initialize tracker
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ export class DashDownloader {
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
urls.map(async (url) => {
|
urls.map(async (url) => {
|
||||||
const result = await fetch(url, { method: 'HEAD', signal });
|
const result = await fetch(getProxyUrl(url), { method: 'HEAD', signal });
|
||||||
|
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
const contentLength = result.headers.get('Content-Length');
|
const contentLength = result.headers.get('Content-Length');
|
||||||
|
|
@ -75,7 +75,7 @@ export class DashDownloader {
|
||||||
|
|
||||||
onProgress?.(new SegmentedDownloadProgress(downloadedBytes, totalSize ?? undefined, i, totalSegments));
|
onProgress?.(new SegmentedDownloadProgress(downloadedBytes, totalSize ?? undefined, i, totalSegments));
|
||||||
|
|
||||||
const url = urls[i];
|
const url = getProxyUrl(urls[i]);
|
||||||
const segmentResponse = await fetch(url, { signal });
|
const segmentResponse = await fetch(url, { signal });
|
||||||
|
|
||||||
if (!segmentResponse.ok) {
|
if (!segmentResponse.ok) {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { SegmentedDownloadProgress } from './progressEvents';
|
import { SegmentedDownloadProgress } from './progressEvents';
|
||||||
|
import { getProxyUrl } from './proxy-utils';
|
||||||
|
|
||||||
export class HlsDownloader {
|
export class HlsDownloader {
|
||||||
constructor() {}
|
constructor() {}
|
||||||
|
|
@ -6,12 +7,12 @@ export class HlsDownloader {
|
||||||
async downloadHlsStream(masterUrl, options = {}) {
|
async downloadHlsStream(masterUrl, options = {}) {
|
||||||
const { onProgress, signal } = options;
|
const { onProgress, signal } = options;
|
||||||
|
|
||||||
const response = await fetch(masterUrl, { signal });
|
const response = await fetch(getProxyUrl(masterUrl), { signal });
|
||||||
const masterText = await response.text();
|
const masterText = await response.text();
|
||||||
|
|
||||||
const variantUrl = this.getBestVariantUrl(masterUrl, masterText);
|
const variantUrl = this.getBestVariantUrl(masterUrl, masterText);
|
||||||
|
|
||||||
const mediaResponse = await fetch(variantUrl, { signal });
|
const mediaResponse = await fetch(getProxyUrl(variantUrl), { signal });
|
||||||
const mediaText = await mediaResponse.text();
|
const mediaText = await mediaResponse.text();
|
||||||
|
|
||||||
const segments = this.parseMediaPlaylist(variantUrl, mediaText);
|
const segments = this.parseMediaPlaylist(variantUrl, mediaText);
|
||||||
|
|
@ -29,7 +30,7 @@ export class HlsDownloader {
|
||||||
onProgress?.(new SegmentedDownloadProgress(downloadedBytes, undefined, i, totalSegments));
|
onProgress?.(new SegmentedDownloadProgress(downloadedBytes, undefined, i, totalSegments));
|
||||||
|
|
||||||
const segmentUrl = segments[i];
|
const segmentUrl = segments[i];
|
||||||
const segmentResponse = await fetch(segmentUrl, { signal });
|
const segmentResponse = await fetch(getProxyUrl(segmentUrl), { signal });
|
||||||
|
|
||||||
if (!segmentResponse.ok) {
|
if (!segmentResponse.ok) {
|
||||||
throw new Error(`Failed to fetch segment ${i}: ${segmentResponse.status}`);
|
throw new Error(`Failed to fetch segment ${i}: ${segmentResponse.status}`);
|
||||||
|
|
|
||||||
24
js/player.js
24
js/player.js
|
|
@ -22,6 +22,7 @@ import {
|
||||||
import { audioContextManager } from './audio-context.js';
|
import { audioContextManager } from './audio-context.js';
|
||||||
import { isIos, isSafari } from './platform-detection.js';
|
import { isIos, isSafari } from './platform-detection.js';
|
||||||
import { db } from './db.js';
|
import { db } from './db.js';
|
||||||
|
import { getProxyUrl } from './proxy-utils.js';
|
||||||
|
|
||||||
import { SVG_CLOCK, SVG_ATMOS } from './icons.js';
|
import { SVG_CLOCK, SVG_ATMOS } from './icons.js';
|
||||||
import { UIRenderer } from './ui.js';
|
import { UIRenderer } from './ui.js';
|
||||||
|
|
@ -133,17 +134,25 @@ export class Player {
|
||||||
},
|
},
|
||||||
abr: {
|
abr: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
// Start with a low bandwidth estimate (200kbps) so it plays instantly
|
|
||||||
// on slow connections and smoothly scales UP to Hi-Fi if the connection allows.
|
|
||||||
defaultBandwidthEstimate: 100000,
|
defaultBandwidthEstimate: 100000,
|
||||||
switchInterval: 1, // Check more frequently
|
switchInterval: 1,
|
||||||
bandwidthDowngradeTarget: 0.8, // Downgrade more aggressively if bandwidth drops
|
bandwidthDowngradeTarget: 0.8,
|
||||||
restrictToElementSize: false,
|
restrictToElementSize: false,
|
||||||
},
|
},
|
||||||
mediaSource: {
|
mediaSource: {
|
||||||
codecSwitchingStrategy: 'smooth',
|
codecSwitchingStrategy: 'smooth',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
this.shakaPlayer.getNetworkingEngine().registerRequestFilter((type, request) => {
|
||||||
|
if (type === shaka.net.NetworkingEngine.RequestType.SEGMENT) {
|
||||||
|
const uris = request.uris;
|
||||||
|
for (let i = 0; i < uris.length; i++) {
|
||||||
|
if (uris[i].includes('tidal.com')) {
|
||||||
|
uris[i] = getProxyUrl(uris[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
this.shakaPlayer.addEventListener('adaptation', this.updateAdaptiveQualityBadge.bind(this));
|
this.shakaPlayer.addEventListener('adaptation', this.updateAdaptiveQualityBadge.bind(this));
|
||||||
this.shakaPlayer.addEventListener('variantchanged', this.updateAdaptiveQualityBadge.bind(this));
|
this.shakaPlayer.addEventListener('variantchanged', this.updateAdaptiveQualityBadge.bind(this));
|
||||||
|
|
||||||
|
|
@ -1266,6 +1275,13 @@ export class Player {
|
||||||
// which delays the event loop and natively adds gap/latency
|
// which delays the event loop and natively adds gap/latency
|
||||||
await this.safePlay(activeElement);
|
await this.safePlay(activeElement);
|
||||||
} else {
|
} else {
|
||||||
|
if (this.shakaInitialized) {
|
||||||
|
try {
|
||||||
|
this.shakaPlayer.unload();
|
||||||
|
this.shakaPlayer.detach();
|
||||||
|
} catch {}
|
||||||
|
this.shakaInitialized = false;
|
||||||
|
}
|
||||||
activeElement.src = streamUrl;
|
activeElement.src = streamUrl;
|
||||||
this.applyAudioEffects();
|
this.applyAudioEffects();
|
||||||
this.updateAdaptiveQualityBadge();
|
this.updateAdaptiveQualityBadge();
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,53 @@ import purgecss from 'vite-plugin-purgecss';
|
||||||
import { execSync } from 'child_process';
|
import { execSync } from 'child_process';
|
||||||
import { playwright } from '@vitest/browser-playwright';
|
import { playwright } from '@vitest/browser-playwright';
|
||||||
|
|
||||||
|
function proxyAudioPlugin() {
|
||||||
|
return {
|
||||||
|
name: 'proxy-audio-dev',
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use('/proxy-audio', async (req, res) => {
|
||||||
|
const url = new URL(req.url, 'http://localhost');
|
||||||
|
const targetUrl = url.searchParams.get('url');
|
||||||
|
|
||||||
|
if (!targetUrl) {
|
||||||
|
res.writeHead(400);
|
||||||
|
res.end('Missing url parameter');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers = new Headers();
|
||||||
|
headers.set('Origin', 'https://listen.tidal.com');
|
||||||
|
headers.set(
|
||||||
|
'User-Agent',
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||||
|
);
|
||||||
|
|
||||||
|
const upstream = await fetch(targetUrl, {
|
||||||
|
method: req.method,
|
||||||
|
headers,
|
||||||
|
redirect: 'follow',
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = Buffer.from(await upstream.arrayBuffer());
|
||||||
|
|
||||||
|
res.writeHead(upstream.status, {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
||||||
|
'Access-Control-Expose-Headers': '*',
|
||||||
|
'content-type': upstream.headers.get('content-type') || 'application/octet-stream',
|
||||||
|
'content-length': body.length,
|
||||||
|
});
|
||||||
|
res.end(body);
|
||||||
|
} catch (error) {
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end('Proxy Error: ' + error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function getGitCommitHash() {
|
function getGitCommitHash() {
|
||||||
try {
|
try {
|
||||||
return execSync('git rev-parse --short HEAD').toString().trim();
|
return execSync('git rev-parse --short HEAD').toString().trim();
|
||||||
|
|
@ -80,6 +127,7 @@ export default defineConfig((_options) => {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
|
proxyAudioPlugin(),
|
||||||
purgecss({
|
purgecss({
|
||||||
variables: false, // DO NOT REMOVE UNUSED VARIABLES (breaks web components like am-lyrics)
|
variables: false, // DO NOT REMOVE UNUSED VARIABLES (breaks web components like am-lyrics)
|
||||||
safelist: {
|
safelist: {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue