Merge pull request #607 from monochrome-music/claude/trusting-engelbart-c48380
Enable AudioContext/EQ for all users via proxy CORS; fix recommendations showing Unknown Artist
This commit is contained in:
commit
2f1944edb9
6 changed files with 277 additions and 88 deletions
|
|
@ -115,8 +115,8 @@
|
|||
</head>
|
||||
|
||||
<body>
|
||||
<audio id="audio-player" style="display: none"></audio>
|
||||
<video id="video-player" style="display: none"></video>
|
||||
<audio id="audio-player" crossorigin="anonymous" style="display: none"></audio>
|
||||
<video id="video-player" crossorigin="anonymous" style="display: none"></video>
|
||||
<div id="context-menu">
|
||||
<ul>
|
||||
<li data-action="shuffle-play-card" data-type-filter="album,playlist,mix,user-playlist">
|
||||
|
|
|
|||
140
js/HiFi.ts
140
js/HiFi.ts
|
|
@ -1590,9 +1590,12 @@ class HiFiClient {
|
|||
const attr = inc.attributes ?? ({} as JsonApiIncludeAttributes);
|
||||
|
||||
let pic_id: string | null = null;
|
||||
const art_data = inc.relationships?.profileArt?.data;
|
||||
if (Array.isArray(art_data) && art_data.length > 0) {
|
||||
const artwork = artworks_map[art_data[0].id];
|
||||
const art_refs_artist = (() => {
|
||||
const d = inc.relationships?.profileArt?.data;
|
||||
return Array.isArray(d) ? d : d ? [d as JsonApiRef] : [];
|
||||
})();
|
||||
if (art_refs_artist.length > 0) {
|
||||
const artwork = artworks_map[art_refs_artist[0].id];
|
||||
const files = artwork?.attributes?.files;
|
||||
if (Array.isArray(files) && files[0]?.href) {
|
||||
pic_id = HiFiClient.#extractUuidFromTidalUrl(files[0].href);
|
||||
|
|
@ -1650,15 +1653,21 @@ class HiFiClient {
|
|||
if (i.type === 'artists') artists_map[i.id] = i;
|
||||
}
|
||||
|
||||
const toArray = (data: unknown): JsonApiRef[] => {
|
||||
if (Array.isArray(data)) return data as JsonApiRef[];
|
||||
if (data && typeof data === 'object') return [data as JsonApiRef];
|
||||
return [];
|
||||
};
|
||||
|
||||
const resolveAlbum = (entry: JsonApiRef): TidalSimilarAlbum => {
|
||||
const aid = entry.id;
|
||||
const inc = albums_map[aid] ?? ({} as JsonApiInclude);
|
||||
const attr = inc.attributes ?? ({} as JsonApiIncludeAttributes);
|
||||
|
||||
let cover_id: string | null = null;
|
||||
const art_data = inc.relationships?.coverArt?.data;
|
||||
if (Array.isArray(art_data) && art_data.length > 0) {
|
||||
const artwork = artworks_map[art_data[0].id];
|
||||
const art_refs = toArray(inc.relationships?.coverArt?.data);
|
||||
if (art_refs.length > 0) {
|
||||
const artwork = artworks_map[art_refs[0].id];
|
||||
const files = artwork?.attributes?.files;
|
||||
if (Array.isArray(files) && files[0]?.href) {
|
||||
cover_id = HiFiClient.#extractUuidFromTidalUrl(files[0].href);
|
||||
|
|
@ -1666,16 +1675,14 @@ class HiFiClient {
|
|||
}
|
||||
|
||||
const artist_list: Array<{ id: number; name: string }> = [];
|
||||
const artists_data = inc.relationships?.artists?.data;
|
||||
if (Array.isArray(artists_data)) {
|
||||
for (const a_entry of artists_data) {
|
||||
const a_obj = artists_map[a_entry.id];
|
||||
if (a_obj) {
|
||||
artist_list.push({
|
||||
id: Number(a_obj.id),
|
||||
name: a_obj.attributes?.name ?? '',
|
||||
});
|
||||
}
|
||||
const artist_refs = toArray(inc.relationships?.artists?.data);
|
||||
for (const a_entry of artist_refs) {
|
||||
const a_obj = artists_map[a_entry.id];
|
||||
if (a_obj) {
|
||||
artist_list.push({
|
||||
id: Number(a_obj.id),
|
||||
name: a_obj.attributes?.name ?? '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1722,15 +1729,27 @@ class HiFiClient {
|
|||
|
||||
if (id) {
|
||||
const artist_url = `https://openapi.tidal.com/v2/artists/${id}`;
|
||||
const payload = await this.#fetchJson<any>(
|
||||
artist_url,
|
||||
{
|
||||
countryCode: this.#countryCode,
|
||||
include: 'albums,albums.coverArt,tracks,tracks.albums,biography,profileArt',
|
||||
collapseBy: 'FINGERPRINT',
|
||||
},
|
||||
signal
|
||||
);
|
||||
|
||||
// Fetch v2 artist metadata and v1 top tracks in parallel.
|
||||
// The v2 endpoint gives us profile art and biography but does NOT include
|
||||
// track details or album cover art in its `included` array even when requested,
|
||||
// so we use the v1 toptracks endpoint for complete track/cover data.
|
||||
const [payload, topTracksPayload] = await Promise.all([
|
||||
this.#fetchJson<any>(
|
||||
artist_url,
|
||||
{
|
||||
countryCode: this.#countryCode,
|
||||
include: 'biography,profileArt',
|
||||
collapseBy: 'FINGERPRINT',
|
||||
},
|
||||
signal
|
||||
),
|
||||
this.#fetchJson<TidalListResponse<TidalTrack>>(
|
||||
`https://api.tidal.com/v1/artists/${id}/toptracks`,
|
||||
{ countryCode: this.#countryCode, limit: '15' },
|
||||
signal
|
||||
).catch(() => ({ items: [] as TidalTrack[] })),
|
||||
]);
|
||||
|
||||
const includedMap = new Map<string, any>();
|
||||
if (Array.isArray(payload?.included)) {
|
||||
|
|
@ -1740,14 +1759,13 @@ class HiFiClient {
|
|||
}
|
||||
|
||||
const getPic = (item: any, relName: string) => {
|
||||
if (item?.relationships?.[relName]?.data?.[0]) {
|
||||
const picRef = item.relationships[relName].data[0];
|
||||
const pic = includedMap.get(`artworks:${picRef.id}`);
|
||||
return pic?.attributes?.files?.[0]?.href
|
||||
? HiFiClient.#extractUuidFromTidalUrl(pic.attributes.files[0].href)
|
||||
: null;
|
||||
}
|
||||
return null;
|
||||
const relData = item?.relationships?.[relName]?.data;
|
||||
const picRef = Array.isArray(relData) ? relData[0] : relData;
|
||||
if (!picRef) return null;
|
||||
const pic = includedMap.get(`artworks:${picRef.id}`);
|
||||
return pic?.attributes?.files?.[0]?.href
|
||||
? HiFiClient.#extractUuidFromTidalUrl(pic.attributes.files[0].href)
|
||||
: null;
|
||||
};
|
||||
|
||||
const data = payload?.data;
|
||||
|
|
@ -1755,7 +1773,9 @@ class HiFiClient {
|
|||
if (data?.relationships?.biography?.data) {
|
||||
const bioRef = data.relationships.biography.data;
|
||||
const bioItem =
|
||||
includedMap.get(`biographies:${bioRef.id}`) || includedMap.get(`biography:${bioRef.id}`);
|
||||
includedMap.get(`biographies:${bioRef.id}`) ||
|
||||
includedMap.get(`biography:${bioRef.id}`) ||
|
||||
includedMap.get(`artistBiographies:${bioRef.id}`);
|
||||
if (bioItem) {
|
||||
biography = { text: bioItem.attributes?.text, source: bioItem.attributes?.source };
|
||||
}
|
||||
|
|
@ -1779,59 +1799,15 @@ class HiFiClient {
|
|||
};
|
||||
}
|
||||
|
||||
const albums: any[] = [];
|
||||
const tracks: any[] = [];
|
||||
|
||||
if (data?.relationships?.albums?.data) {
|
||||
for (const ref of data.relationships.albums.data) {
|
||||
const al = includedMap.get(`albums:${ref.id}`);
|
||||
if (al) {
|
||||
albums.push({
|
||||
id: Number(al.id),
|
||||
title: al.attributes?.title,
|
||||
duration: al.attributes?.duration ? 100 : undefined,
|
||||
numberOfTracks: al.attributes?.numberOfItems,
|
||||
releaseDate: al.attributes?.releaseDate,
|
||||
type: al.attributes?.albumType,
|
||||
cover: getPic(al, 'coverArt'),
|
||||
artist: { id: artist_data.id, name: artist_data.name },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data?.relationships?.tracks?.data) {
|
||||
for (const ref of data.relationships.tracks.data) {
|
||||
const tr = includedMap.get(`tracks:${ref.id}`);
|
||||
if (tr) {
|
||||
let albumInfo = undefined;
|
||||
if (tr.relationships?.albums?.data?.[0]) {
|
||||
const aRef = tr.relationships.albums.data[0];
|
||||
const aItem = includedMap.get(`albums:${aRef.id}`);
|
||||
if (aItem) {
|
||||
albumInfo = {
|
||||
id: Number(aItem.id),
|
||||
title: aItem.attributes?.title,
|
||||
cover: getPic(aItem, 'coverArt'),
|
||||
};
|
||||
}
|
||||
}
|
||||
tracks.push({
|
||||
id: Number(tr.id),
|
||||
title: tr.attributes?.title,
|
||||
duration: tr.attributes?.duration ? 100 : undefined,
|
||||
album: albumInfo,
|
||||
artist: { id: artist_data.id, name: artist_data.name },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// v1 toptracks have complete TidalTrack objects with album.cover UUIDs,
|
||||
// proper artist/artists arrays, and real duration values.
|
||||
const tracks: TidalTrack[] = topTracksPayload?.items ?? [];
|
||||
|
||||
return HiFiClient.#jsonResponse({
|
||||
version: HiFiClient.API_VERSION,
|
||||
artist: artist_data,
|
||||
cover,
|
||||
albums: { items: albums },
|
||||
albums: { items: [] },
|
||||
tracks,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -284,6 +284,10 @@ export class LosslessAPI {
|
|||
normalized = { ...normalized, artist: track.artists[0] };
|
||||
}
|
||||
|
||||
if (normalized.artist && (!Array.isArray(normalized.artists) || normalized.artists.length === 0)) {
|
||||
normalized = { ...normalized, artists: [normalized.artist] };
|
||||
}
|
||||
|
||||
const derivedQuality = deriveTrackQuality(normalized);
|
||||
if (derivedQuality && normalized.audioQuality !== derivedQuality) {
|
||||
normalized = { ...normalized, audioQuality: derivedQuality };
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@
|
|||
// Shared Audio Context Manager - handles EQ and provides context for visualizer
|
||||
// Supports 3-32 parametric EQ bands
|
||||
|
||||
import { isIos } from './platform-detection.js';
|
||||
import { equalizerSettings, monoAudioSettings, binauralDspSettings } from './storage.js';
|
||||
import { BinauralDSP } from './binaural-dsp.js';
|
||||
|
||||
// Generate frequency array for given number of bands using logarithmic spacing
|
||||
function generateFrequencies(bandCount, minFreq = 20, maxFreq = 20000) {
|
||||
|
|
@ -473,6 +475,11 @@ class AudioContextManager {
|
|||
|
||||
this.audio = audioElement;
|
||||
|
||||
if (isIos) {
|
||||
console.log('[AudioContext] Skipping Web Audio initialization on iOS for lock screen compatibility');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
||||
|
||||
|
|
@ -483,6 +490,23 @@ class AudioContextManager {
|
|||
this.audioContext = new AudioContext();
|
||||
}
|
||||
|
||||
if (!this.sources.has(audioElement)) {
|
||||
const src = this.audioContext.createMediaElementSource(audioElement);
|
||||
this.sources.set(audioElement, src);
|
||||
}
|
||||
this.source = this.sources.get(audioElement);
|
||||
|
||||
try {
|
||||
this.audioContext.destination.channelCount = Math.min(this.audioContext.destination.maxChannelCount, 8);
|
||||
this.audioContext.destination.channelCountMode = 'explicit';
|
||||
this.audioContext.destination.channelInterpretation = 'discrete';
|
||||
} catch {
|
||||
// Some browsers may not support changing destination channel count
|
||||
}
|
||||
|
||||
this.binauralDsp = new BinauralDSP(this.audioContext);
|
||||
void this._loadBinauralSettings();
|
||||
|
||||
this.analyser = this.audioContext.createAnalyser();
|
||||
this.analyser.fftSize = 1024;
|
||||
this.analyser.smoothingTimeConstant = 0.7;
|
||||
|
|
@ -502,6 +526,8 @@ class AudioContextManager {
|
|||
|
||||
this.monoMergerNode = this.audioContext.createChannelMerger(2);
|
||||
|
||||
this._connectGraph();
|
||||
|
||||
// Auto-recover from unexpected suspensions (e.g. background throttling)
|
||||
this.audioContext.addEventListener('statechange', () => {
|
||||
if (this.audioContext.state === 'interrupted' || this.audioContext.state === 'suspended') {
|
||||
|
|
@ -529,16 +555,198 @@ class AudioContextManager {
|
|||
}
|
||||
if (this.audio === audioElement) return;
|
||||
|
||||
this.audio = audioElement;
|
||||
try {
|
||||
if (this.source) {
|
||||
try {
|
||||
this.source.disconnect();
|
||||
} catch {
|
||||
// node may already be disconnected
|
||||
}
|
||||
}
|
||||
|
||||
this.audio = audioElement;
|
||||
|
||||
if (!this.sources.has(audioElement)) {
|
||||
this.sources.set(audioElement, this.audioContext.createMediaElementSource(audioElement));
|
||||
}
|
||||
this.source = this.sources.get(audioElement);
|
||||
|
||||
if (this.isInitialized) {
|
||||
this._connectGraph();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('changeSource failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect the audio graph based on EQ and mono audio state.
|
||||
* Uses connect-before-disconnect ordering to avoid audio dropouts:
|
||||
* the new chain is wired up first, then the old connections are torn down.
|
||||
* Only functional when the extension is active (crossorigin required for createMediaElementSource).
|
||||
*/
|
||||
_connectGraph() {
|
||||
if (!this.isInitialized || !this.audioContext) return;
|
||||
if (!this.isInitialized || !this.source || !this.audioContext) return;
|
||||
|
||||
// Ensure graphic EQ nodes exist
|
||||
if (this.geqFilters.length === 0 && this.isGraphicEQEnabled) {
|
||||
this._createGraphicEQ();
|
||||
}
|
||||
|
||||
// Helper: connect a chain segment from lastNode through graphic EQ (if enabled) to analyser -> volume -> dest
|
||||
const connectTail = (lastNode) => {
|
||||
if (this.isGraphicEQEnabled && this.geqFilters.length > 0) {
|
||||
lastNode.connect(this.geqPreampNode);
|
||||
this.geqPreampNode.connect(this.geqFilters[0]);
|
||||
for (let i = 0; i < this.geqFilters.length - 1; i++) {
|
||||
this.geqFilters[i].connect(this.geqFilters[i + 1]);
|
||||
}
|
||||
this.geqFilters[this.geqFilters.length - 1].connect(this.geqOutputNode);
|
||||
this.geqOutputNode.connect(this.analyser);
|
||||
} else {
|
||||
lastNode.connect(this.analyser);
|
||||
}
|
||||
this.analyser.connect(this.volumeNode);
|
||||
this.volumeNode.connect(this.audioContext.destination);
|
||||
};
|
||||
|
||||
try {
|
||||
// Ensure mono gain node exists if needed
|
||||
if (this.isMonoAudioEnabled && this.monoMergerNode && !this.monoGainNode) {
|
||||
this.monoGainNode = this.audioContext.createGain();
|
||||
this.monoGainNode.gain.value = 0.5;
|
||||
}
|
||||
|
||||
// --- 1. Disconnect all existing connections ---
|
||||
const safeDisconnect = (node) => {
|
||||
try {
|
||||
node?.disconnect();
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
};
|
||||
safeDisconnect(this.source);
|
||||
safeDisconnect(this.monoGainNode);
|
||||
safeDisconnect(this.monoMergerNode);
|
||||
if (this.binauralDsp) {
|
||||
const { input, output } = this.binauralDsp.getNodes();
|
||||
safeDisconnect(input);
|
||||
safeDisconnect(output);
|
||||
}
|
||||
safeDisconnect(this.preampNode);
|
||||
this.filters.forEach(safeDisconnect);
|
||||
safeDisconnect(this.outputNode);
|
||||
safeDisconnect(this.msSplitter);
|
||||
safeDisconnect(this.msEncoderMidL);
|
||||
safeDisconnect(this.msEncoderMidR);
|
||||
safeDisconnect(this.msEncoderSideL);
|
||||
safeDisconnect(this.msEncoderSideR);
|
||||
safeDisconnect(this.msMidInput);
|
||||
safeDisconnect(this.msSideInput);
|
||||
this.midFilters.forEach(safeDisconnect);
|
||||
this.sideFilters.forEach(safeDisconnect);
|
||||
safeDisconnect(this.midOutputNode);
|
||||
safeDisconnect(this.sideOutputNode);
|
||||
safeDisconnect(this.msDecoderMidToL);
|
||||
safeDisconnect(this.msDecoderSideToL);
|
||||
safeDisconnect(this.msDecoderMidToR);
|
||||
safeDisconnect(this.msDecoderSideToR);
|
||||
safeDisconnect(this.msLMix);
|
||||
safeDisconnect(this.msRMix);
|
||||
safeDisconnect(this.msMerger);
|
||||
safeDisconnect(this.msOutputNode);
|
||||
safeDisconnect(this.geqPreampNode);
|
||||
this.geqFilters.forEach(safeDisconnect);
|
||||
safeDisconnect(this.geqOutputNode);
|
||||
safeDisconnect(this.analyser);
|
||||
safeDisconnect(this.volumeNode);
|
||||
|
||||
// --- 2. Reconnect the graph ---
|
||||
let lastNode = this.source;
|
||||
|
||||
if (this.isMonoAudioEnabled && this.monoMergerNode) {
|
||||
this.source.connect(this.monoGainNode);
|
||||
this.monoGainNode.connect(this.monoMergerNode, 0, 0);
|
||||
this.monoGainNode.connect(this.monoMergerNode, 0, 1);
|
||||
lastNode = this.monoMergerNode;
|
||||
}
|
||||
|
||||
if (this.isBinauralEnabled && this.binauralDsp) {
|
||||
const { input, output } = this.binauralDsp.getNodes();
|
||||
lastNode.connect(input);
|
||||
this.binauralDsp.reconnect();
|
||||
lastNode = output;
|
||||
}
|
||||
|
||||
if (this.isEQEnabled && this.filters.length > 0) {
|
||||
const useMS = this.msEnabled && this.midFilters.length > 0 && this.sideFilters.length > 0;
|
||||
|
||||
if (this.preampNode) {
|
||||
lastNode.connect(this.preampNode);
|
||||
lastNode = this.preampNode;
|
||||
}
|
||||
|
||||
if (useMS) {
|
||||
lastNode.connect(this.msSplitter);
|
||||
|
||||
this.msSplitter.connect(this.msEncoderMidL, 0);
|
||||
this.msSplitter.connect(this.msEncoderMidR, 1);
|
||||
this.msEncoderMidL.connect(this.msMidInput);
|
||||
this.msEncoderMidR.connect(this.msMidInput);
|
||||
|
||||
this.msSplitter.connect(this.msEncoderSideL, 0);
|
||||
this.msSplitter.connect(this.msEncoderSideR, 1);
|
||||
this.msEncoderSideL.connect(this.msSideInput);
|
||||
this.msEncoderSideR.connect(this.msSideInput);
|
||||
|
||||
this.msMidInput.connect(this.midFilters[0]);
|
||||
for (let i = 0; i < this.midFilters.length - 1; i++) {
|
||||
this.midFilters[i].connect(this.midFilters[i + 1]);
|
||||
}
|
||||
this.midFilters[this.midFilters.length - 1].connect(this.midOutputNode);
|
||||
|
||||
this.msSideInput.connect(this.sideFilters[0]);
|
||||
for (let i = 0; i < this.sideFilters.length - 1; i++) {
|
||||
this.sideFilters[i].connect(this.sideFilters[i + 1]);
|
||||
}
|
||||
this.sideFilters[this.sideFilters.length - 1].connect(this.sideOutputNode);
|
||||
|
||||
this.midOutputNode.connect(this.msDecoderMidToL);
|
||||
this.sideOutputNode.connect(this.msDecoderSideToL);
|
||||
this.msDecoderMidToL.connect(this.msLMix);
|
||||
this.msDecoderSideToL.connect(this.msLMix);
|
||||
|
||||
this.midOutputNode.connect(this.msDecoderMidToR);
|
||||
this.sideOutputNode.connect(this.msDecoderSideToR);
|
||||
this.msDecoderMidToR.connect(this.msRMix);
|
||||
this.msDecoderSideToR.connect(this.msRMix);
|
||||
|
||||
this.msLMix.connect(this.msMerger, 0, 0);
|
||||
this.msRMix.connect(this.msMerger, 0, 1);
|
||||
this.msMerger.connect(this.msOutputNode);
|
||||
|
||||
connectTail(this.msOutputNode);
|
||||
} else {
|
||||
lastNode.connect(this.filters[0]);
|
||||
for (let i = 0; i < this.filters.length - 1; i++) {
|
||||
this.filters[i].connect(this.filters[i + 1]);
|
||||
}
|
||||
this.filters[this.filters.length - 1].connect(this.outputNode);
|
||||
connectTail(this.outputNode);
|
||||
}
|
||||
} else {
|
||||
connectTail(lastNode);
|
||||
}
|
||||
|
||||
this._notifyGraphChange();
|
||||
} catch (e) {
|
||||
console.warn('[AudioContext] Failed to connect graph:', e);
|
||||
try {
|
||||
this.source.connect(this.audioContext.destination);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -663,7 +663,7 @@ export class Player {
|
|||
const preloader = new Audio();
|
||||
preloader.preload = 'auto';
|
||||
preloader.muted = true;
|
||||
preloader.src = streamUrl;
|
||||
preloader.src = getProxyUrl(streamUrl);
|
||||
streamInfo.preloader = preloader; // Hold reference
|
||||
}
|
||||
}
|
||||
|
|
@ -1282,7 +1282,7 @@ export class Player {
|
|||
} catch {}
|
||||
this.shakaInitialized = false;
|
||||
}
|
||||
activeElement.src = streamUrl;
|
||||
activeElement.src = getProxyUrl(streamUrl);
|
||||
this.applyAudioEffects();
|
||||
this.updateAdaptiveQualityBadge();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export const getProxyUrl = (url) => {
|
||||
if (window.__tidalOriginExtension) return url;
|
||||
if (url.startsWith('https://audio-proxy.binimum.org/')) return url;
|
||||
return `https://audio-proxy.binimum.org/proxy-audio?url=${url}`;
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue