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:
binimum 2026-04-22 21:09:27 +01:00 committed by GitHub
commit 2f1944edb9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 277 additions and 88 deletions

View file

@ -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">

View file

@ -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,
});
}

View file

@ -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 };

View file

@ -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 */
}
}
}
/**

View file

@ -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();

View file

@ -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}`;
};