Merge branch 'main' of github.com:monochrome-music/monochrome

This commit is contained in:
Samidy 2026-04-18 14:43:09 +03:00
commit 9b78340939
6 changed files with 296 additions and 434 deletions

View file

@ -1,67 +0,0 @@
export async function onRequest(context) {
const { request } = context;
const url = new URL(request.url);
const targetUrl = url.searchParams.get('url');
if (!targetUrl) {
return new Response('Missing url parameter', { status: 400 });
}
try {
const cacheUrl = new URL(request.url);
try {
const tidalUrl = new URL(targetUrl);
cacheUrl.searchParams.set('cache_key', tidalUrl.pathname);
} catch (e) {}
const cacheKey = new Request(cacheUrl.toString(), request);
const cache = caches.default;
let response = await cache.match(cacheKey);
if (!response) {
console.log('Cache Miss. Fetching from Tidal...');
const headers = new Headers(request.headers);
headers.delete('host');
headers.delete('referer');
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'
);
response = await fetch(targetUrl, {
method: request.method,
headers: headers,
redirect: 'follow',
cf: {
cacheTtl: 2592000,
cacheEverything: true,
},
});
if (request.method === 'GET' && response.ok) {
const cacheResponse = new Response(response.body, response);
cacheResponse.headers.set('Access-Control-Allow-Origin', '*');
cacheResponse.headers.set('Cache-Control', 'public, max-age=2592000');
cacheResponse.headers.delete('Set-Cookie');
context.waitUntil(cache.put(cacheKey, cacheResponse.clone()));
response = cacheResponse;
}
} else {
console.log('Cache Hit! Serving from Edge.');
}
const newResponse = new Response(response.body, response);
newResponse.headers.set('Access-Control-Allow-Origin', '*');
newResponse.headers.set('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
newResponse.headers.set('Access-Control-Expose-Headers', '*');
newResponse.headers.delete('content-security-policy');
newResponse.headers.delete('x-frame-options');
return newResponse;
} catch (error) {
return new Response('Proxy Error: ' + error.message, { status: 500 });
}
}

View file

@ -115,8 +115,8 @@
</head>
<body>
<audio id="audio-player" style="display: none" crossorigin="anonymous"></audio>
<video id="video-player" style="display: none" crossorigin="anonymous"></video>
<audio id="audio-player" style="display: none"></audio>
<video id="video-player" 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

@ -1340,11 +1340,18 @@ class HiFiClient {
force: unauthorized,
});
const headers: Record<string, string> = {
authorization: `Bearer ${token}`,
};
if (final.includes('openapi.tidal.com')) {
// Prefer JSON:API for OpenAPI endpoints, but do not require it exclusively.
// Some endpoints/proxies can still return compatible JSON.
headers['Accept'] = 'application/vnd.api+json, application/json;q=0.9, */*;q=0.8';
}
try {
res = await fetch(final, {
headers: {
authorization: `Bearer ${token}`,
},
headers,
signal,
});
} catch (err: unknown) {
@ -1744,10 +1751,21 @@ class HiFiClient {
};
const data = payload?.data;
let biography: any = null;
if (data?.relationships?.biography?.data) {
const bioRef = data.relationships.biography.data;
const bioItem =
includedMap.get(`biographies:${bioRef.id}`) || includedMap.get(`biography:${bioRef.id}`);
if (bioItem) {
biography = { text: bioItem.attributes?.text, source: bioItem.attributes?.source };
}
}
const artist_data: any = {
id: Number(data?.id || id),
name: data?.attributes?.name || '',
picture: getPic(data, 'profileArt') || data?.attributes?.selectedAlbumCoverFallback || null,
biography: biography,
};
const picture = artist_data.picture;
@ -2019,6 +2037,110 @@ class HiFiClient {
): Promise<TidalResponse<SearchResponse>> {
const { q, s, a, al, v, p, i, offset = 0, limit = 25 } = options;
const parseOpenApiSearch = (jsonApi: any): SearchResponse['data'] => {
if (!jsonApi || !jsonApi.data) return {};
const includedMap = new Map<string, any>();
if (Array.isArray(jsonApi.included)) {
for (const item of jsonApi.included) {
includedMap.set(`${item.type}:${item.id}`, item);
}
}
const resolveArtworkId = (item: any, relName: string) => {
const ref = item?.relationships?.[relName]?.data?.[0];
if (!ref) return null;
const artwork = includedMap.get(`artworks:${ref.id}`);
const href = artwork?.attributes?.files?.[0]?.href;
return href ? HiFiClient.#extractUuidFromTidalUrl(href) : null;
};
const resolveArtists = (item: any) => {
const refs = item?.relationships?.artists?.data;
if (!Array.isArray(refs)) return [];
return refs.map((art: any) => {
const aItem = includedMap.get(`artists:${art.id}`);
return {
id: Number(art.id),
name: aItem?.attributes?.name ?? '',
};
});
};
const resolveItem = (ref: { id: string; type: string }) => {
const item = includedMap.get(`${ref.type}:${ref.id}`);
if (!item) return null;
const attrs = item.attributes || {};
const mapped: any = {
id: Number(item.id) || item.id,
...attrs,
};
if (item.type === 'artists') {
mapped.type = 'artist';
mapped.name = attrs.name ?? '';
mapped.picture = resolveArtworkId(item, 'profileArt');
} else if (item.type === 'albums') {
const artists = resolveArtists(item);
mapped.type = 'album';
mapped.title = attrs.title ?? '';
mapped.cover = resolveArtworkId(item, 'coverArt');
mapped.artists = artists;
if (artists.length > 0) mapped.artist = artists[0];
} else if (item.type === 'tracks') {
const artists = resolveArtists(item);
mapped.type = 'track';
mapped.title = attrs.title ?? '';
mapped.artists = artists;
if (artists.length > 0) mapped.artist = artists[0];
const albumRef = item.relationships?.albums?.data?.[0];
if (albumRef) {
const albumItem = includedMap.get(`albums:${albumRef.id}`);
mapped.album = {
id: Number(albumRef.id),
title: albumItem?.attributes?.title ?? '',
cover: albumItem ? resolveArtworkId(albumItem, 'coverArt') : null,
};
}
} else if (item.type === 'videos') {
const artists = resolveArtists(item);
mapped.type = 'video';
mapped.title = attrs.title ?? '';
mapped.artists = artists;
if (artists.length > 0) mapped.artist = artists[0];
mapped.imageId = resolveArtworkId(item, 'image');
} else if (item.type === 'playlists') {
mapped.type = 'playlist';
mapped.title = attrs.name ?? '';
mapped.image = resolveArtworkId(item, 'coverArt');
}
return mapped;
};
const relationships = jsonApi.data.relationships || {};
const mapBucket = (relName: string) => {
const relData = relationships[relName]?.data;
if (!Array.isArray(relData)) return undefined;
const items = relData.map(resolveItem).filter(Boolean);
return {
items,
totalNumberOfItems: items.length,
limit,
offset,
};
};
return {
artists: mapBucket('artists'),
albums: mapBucket('albums'),
tracks: mapBucket('tracks'),
videos: mapBucket('videos'),
playlists: mapBucket('playlists'),
};
};
if (i) {
// try filtered track search first
try {
@ -2037,58 +2159,69 @@ class HiFiClient {
if (err instanceof ResponseError && ![400, 404].includes(err.status)) throw err;
// fallback to text search
}
const fallback = await this.#fetchJson<SearchResponse['data']>(
'https://api.tidal.com/v1/search/tracks',
const fallback = await this.#fetchJson<any>(
`https://openapi.tidal.com/v2/searchResults/${encodeURIComponent(i)}`,
{
query: i,
limit,
offset,
include: 'tracks,tracks.artists,tracks.albums,tracks.albums.coverArt',
countryCode: this.#countryCode,
},
signal
);
return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data: fallback });
return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data: parseOpenApiSearch(fallback) });
}
const includeQ =
'albums,albums.coverArt,albums.artists,tracks,tracks.artists,tracks.albums,tracks.albums.coverArt,artists,playlists,videos';
const includeS = 'tracks,tracks.artists,tracks.albums,tracks.albums.coverArt';
const includeA = 'artists,artists.profileArt,tracks,tracks.artists,tracks.albums,tracks.albums.coverArt';
const includeAl = 'albums,albums.artists,albums.coverArt';
const includeV = 'videos,videos.artists,videos.image';
const includeP = 'playlists,playlists.coverArt';
const mapping: Array<[string | undefined, string, Params]> = [
[
q,
'https://api.tidal.com/v1/search',
`https://openapi.tidal.com/v2/searchResults/${encodeURIComponent(q || '')}`,
{
query: q,
limit,
offset,
types: 'ARTISTS,ALBUMS,TRACKS,VIDEOS,PLAYLISTS',
include: includeQ,
countryCode: this.#countryCode,
},
],
[s, 'https://api.tidal.com/v1/search/tracks', { query: s, limit, offset, countryCode: this.#countryCode }],
[
s,
`https://openapi.tidal.com/v2/searchResults/${encodeURIComponent(s || '')}`,
{ limit, offset, include: includeS, countryCode: this.#countryCode },
],
[
a,
'https://api.tidal.com/v1/search/top-hits',
{ query: a, limit, offset, types: 'ARTISTS,TRACKS', countryCode: this.#countryCode },
`https://openapi.tidal.com/v2/searchResults/${encodeURIComponent(a || '')}`,
{ limit, offset, include: includeA, countryCode: this.#countryCode },
],
[
al,
'https://api.tidal.com/v1/search/top-hits',
{ query: al, limit, offset, types: 'ALBUMS', countryCode: this.#countryCode },
`https://openapi.tidal.com/v2/searchResults/${encodeURIComponent(al || '')}`,
{ limit, offset, include: includeAl, countryCode: this.#countryCode },
],
[
v,
'https://api.tidal.com/v1/search/top-hits',
{ query: v, limit, offset, types: 'VIDEOS', countryCode: this.#countryCode },
`https://openapi.tidal.com/v2/searchResults/${encodeURIComponent(v || '')}`,
{ limit, offset, include: includeV, countryCode: this.#countryCode },
],
[
p,
'https://api.tidal.com/v1/search/top-hits',
{ query: p, limit, offset, types: 'PLAYLISTS', countryCode: this.#countryCode },
`https://openapi.tidal.com/v2/searchResults/${encodeURIComponent(p || '')}`,
{ limit, offset, include: includeP, countryCode: this.#countryCode },
],
];
for (const [val, url, params] of mapping) {
if (val) {
const data = await this.#fetchJson<SearchResponse['data']>(url, params, signal);
return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data });
const data = await this.#fetchJson<any>(url, params, signal);
return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data: parseOpenApiSearch(data) });
}
}

240
js/api.js
View file

@ -62,6 +62,110 @@ export class LosslessAPI {
async fetchWithRetry(relativePath, options = {}) {
const type = options.type || 'api';
const isSearchRequest = relativePath.startsWith('/search/');
const getInstances = async (forceRefresh = false) => {
if (forceRefresh && this.settings && typeof this.settings.refreshInstances === 'function') {
try {
await this.settings.refreshInstances();
} catch (refreshError) {
console.warn('Failed to refresh API instances from uptime workers:', refreshError);
}
}
let instances = await this.settings.getInstances(type);
if (options.userInstancesOnly) {
instances = instances.filter((i) => i.isUser);
if (instances.length === 0) {
throw new Error(`No user API instances configured for type: ${type}`);
}
} else if (instances.length === 0) {
throw new Error(`No API instances configured for type: ${type}`);
}
if (options.minVersion) {
instances = instances.filter((instance) => {
if (!instance.version) return false;
return parseFloat(instance.version) >= parseFloat(options.minVersion);
});
if (instances.length === 0) {
throw new Error(
`No API instances configured for type: ${type} with minVersion: ${options.minVersion}`
);
}
}
if (options.allowedDomains) {
instances = instances.filter((instance) => {
const url = typeof instance === 'string' ? instance : instance.url;
return options.allowedDomains.some((domain) => url.includes(domain));
});
if (instances.length === 0) {
throw new Error(
`No API instances configured for type: ${type} matching allowedDomains: ${options.allowedDomains.join(', ')}`
);
}
}
return instances;
};
const tryInstances = async (instances) => {
const maxTotalAttempts = instances.length * 2; // Allow some retries across instances
let lastError = null;
let instanceIndex = Math.floor(Math.random() * instances.length);
for (let attempt = 1; attempt <= maxTotalAttempts; attempt++) {
const instance = instances[instanceIndex % instances.length];
const baseUrl = typeof instance === 'string' ? instance : instance.url;
const url = baseUrl.endsWith('/')
? `${baseUrl}${relativePath.substring(1)}`
: `${baseUrl}${relativePath}`;
try {
const response = await fetch(url, { signal: options.signal });
if (response.status === 429) {
console.warn(`Rate limit hit on ${baseUrl}. Trying next instance...`);
instanceIndex++;
await delay(500);
continue;
}
if (response.ok) {
return response;
}
if (response.status === 401) {
const errorData = await response
.clone()
.json()
.catch(() => null);
if (errorData?.subStatus === 11002) {
console.warn(`Auth failed on ${baseUrl}. Trying next instance...`);
instanceIndex++;
continue;
}
}
if (response.status >= 500) {
console.warn(`Server error ${response.status} on ${baseUrl}. Trying next instance...`);
instanceIndex++;
continue;
}
lastError = new Error(`Request failed with status ${response.status}`);
instanceIndex++;
} catch (error) {
if (error.name === 'AbortError') throw error;
lastError = error;
console.warn(`Network error on ${baseUrl}: ${error.message}. Trying next instance...`);
instanceIndex++;
await delay(200);
}
}
throw lastError || new Error(`All API instances failed for: ${relativePath}`);
};
if (devModeSettings.isEnabled()) {
const devBaseUrl = devModeSettings.getUrl().replace(/\/+$/, '');
@ -78,101 +182,45 @@ export class LosslessAPI {
return response;
}
if (type !== 'streaming') {
const shouldTryNative = type !== 'streaming';
if (shouldTryNative) {
try {
if (import.meta.env.DEV) {
console.log(relativePath);
}
// HiFiClient.query fans out across the native TIDAL endpoints used by the route
// implementation, including api.tidal.com and openapi.tidal.com where applicable.
return await HiFiClient.instance.query(relativePath);
} catch (err) {
if (options.directOnly) {
throw err;
}
console.warn(
`Direct Tidal API fetch failed for ${relativePath}. Falling back to configured API instances...`,
err
);
if (import.meta.env.DEV && isSearchRequest) {
console.warn(
`[search] native TIDAL query failed for ${relativePath}, trying HiFi worker instances`,
err
);
} else {
console.warn(
`Native TIDAL query failed for ${relativePath}. Falling back to configured HiFi API instances...`,
err
);
}
}
}
let instances = await this.settings.getInstances(type);
if (instances.length === 0) {
throw new Error(`No API instances configured for type: ${type}`);
}
if (options.minVersion) {
instances = instances.filter((instance) => {
if (!instance.version) return false;
return parseFloat(instance.version) >= parseFloat(options.minVersion);
});
if (instances.length === 0) {
throw new Error(`No API instances configured for type: ${type} with minVersion: ${options.minVersion}`);
try {
return await tryInstances(await getInstances(false));
} catch (error) {
if (type === 'streaming' || options.userInstancesOnly) {
throw error;
}
}
if (options.allowedDomains) {
instances = instances.filter((instance) => {
const url = typeof instance === 'string' ? instance : instance.url;
return options.allowedDomains.some((domain) => url.includes(domain));
});
if (instances.length === 0) {
throw new Error(
`No API instances configured for type: ${type} matching allowedDomains: ${options.allowedDomains.join(', ')}`
);
}
}
const maxTotalAttempts = instances.length * 2; // Allow some retries across instances
let lastError = null;
let instanceIndex = Math.floor(Math.random() * instances.length);
for (let attempt = 1; attempt <= maxTotalAttempts; attempt++) {
const instance = instances[instanceIndex % instances.length];
const baseUrl = typeof instance === 'string' ? instance : instance.url;
const url = baseUrl.endsWith('/') ? `${baseUrl}${relativePath.substring(1)}` : `${baseUrl}${relativePath}`;
try {
const response = await fetch(url, { signal: options.signal });
if (response.status === 429) {
console.warn(`Rate limit hit on ${baseUrl}. Trying next instance...`);
instanceIndex++;
await delay(500); // Small delay before trying next instance
continue;
}
if (response.ok) {
return response;
}
if (response.status === 401) {
let errorData = await response.clone().json();
if (errorData?.subStatus === 11002) {
console.warn(`Auth failed on ${baseUrl}. Trying next instance...`);
instanceIndex++;
continue;
}
}
if (response.status >= 500) {
console.warn(`Server error ${response.status} on ${baseUrl}. Trying next instance...`);
instanceIndex++;
continue;
}
lastError = new Error(`Request failed with status ${response.status}`);
instanceIndex++;
} catch (error) {
if (error.name === 'AbortError') throw error;
lastError = error;
console.warn(`Network error on ${baseUrl}: ${error.message}. Trying next instance...`);
instanceIndex++;
await delay(200);
}
}
throw lastError || new Error(`All API instances failed for: ${relativePath}`);
return await tryInstances(await getInstances(true));
}
findSearchSection(source, key, visited) {
@ -454,19 +502,9 @@ export class LosslessAPI {
if (cached) return cached;
try {
// Keep direct TIDAL combined search behavior for normal mode.
// If direct query fails, fall back to hifi-api-compatible scoped searches (?s, ?a, ?al, ?v, ?p).
const response = await this.fetchWithRetry(`/search/?q=${encodeURIComponent(query)}`, {
...options,
directOnly: true,
});
const response = await this.fetchWithRetry(`/search/?q=${encodeURIComponent(query)}`, options);
const data = await response.json();
// Check if backend returned an error or if this looks like individual fallback
if (data.error) {
throw new Error('Fallback to individual searches');
}
const extractSection = (key) => this.normalizeSearchResponse(data, key);
const tracksData = extractSection('tracks');
@ -503,8 +541,12 @@ export class LosslessAPI {
await this.cache.set('search_all', query, results);
return results;
} catch (_error) {
// Fallback to individual searches if the backend proxy doesn't support ?q= or throws
} catch (error) {
if (import.meta.env.DEV) {
console.warn('[search] combined search failed, using HiFi scoped fallback', error);
}
// Final fallback: hifi-api-compatible scoped searches (?s, ?a, ?al, ?v, ?p)
const [tracks, videos, artists, albums, playlists] = await Promise.all([
this.searchTracks(query, options).catch(() => ({ items: [] })),
this.searchVideos(query, options).catch(() => ({ items: [] })),
@ -1126,7 +1168,7 @@ export class LosslessAPI {
const result = { ...artist, albums, eps, tracks, videos };
if (!(primaryResponse instanceof TidalResponse) && !(contentResponse instanceof TidalResponse)) {
if (!(primaryResponse instanceof TidalResponse)) {
await this.cache.set('artist', cacheKey, result);
}
return result;
@ -1247,7 +1289,7 @@ export class LosslessAPI {
if (cached) return cached;
try {
const response = await HiFiClient.instance.query(`/artist/bio/?id=${artistId}`);
const response = await this.fetchWithRetry(`/artist/bio/?id=${artistId}`, { type: 'api' });
if (response.ok) {
const { data } = await response.json();
@ -1507,11 +1549,6 @@ export class LosslessAPI {
};
}
if (streamUrl && streamUrl.includes('tidal.com')) {
const encodedUrl = encodeURIComponent(streamUrl);
streamUrl = `/proxy-audio?url=${encodedUrl}`;
}
const result = { url: streamUrl, rgInfo: manifestRgInfo };
this.streamCache.set(cacheKey, result);
@ -1559,11 +1596,6 @@ export class LosslessAPI {
throw new Error(`Could not resolve video stream URL for ID: ${id}`);
}
if (streamUrl && streamUrl.includes('tidal.com')) {
const encodedUrl = encodeURIComponent(streamUrl);
streamUrl = `/proxy-audio?url=${encodedUrl}`;
}
if (!(lookup instanceof TidalResponse)) {
this.streamCache.set(cacheKey, streamUrl);
}

View file

@ -2,9 +2,7 @@
// 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) {
@ -475,11 +473,6 @@ 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;
@ -490,29 +483,10 @@ 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);
// Enable multichannel passthrough for Atmos/spatial content
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.analyser = this.audioContext.createAnalyser();
this.analyser.fftSize = 1024;
this.analyser.smoothingTimeConstant = 0.7;
// Create binaural DSP processor
this.binauralDsp = new BinauralDSP(this.audioContext);
void this._loadBinauralSettings();
this._createEQ();
this._createGraphicEQ();
this._createMSNodes();
@ -528,15 +502,12 @@ 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') {
console.log(`[AudioContext] State changed to ${this.audioContext.state}, attempting resume`);
// Use a short delay to let the system settle before resuming
setTimeout(() => {
if (this.audioContext && this.audioContext.state !== 'running' && this.source) {
if (this.audioContext && this.audioContext.state !== 'running') {
this.audioContext.resume().catch((e) => {
console.warn('[AudioContext] Auto-resume failed:', e);
});
@ -558,28 +529,7 @@ class AudioContextManager {
}
if (this.audio === audioElement) return;
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);
}
this.audio = audioElement;
}
/**
@ -588,179 +538,7 @@ class AudioContextManager {
* the new chain is wired up first, then the old connections are torn down.
*/
_connectGraph() {
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);
// Binaural DSP disconnects internally
if (this.binauralDsp) {
const { input, output } = this.binauralDsp.getNodes();
safeDisconnect(input);
safeDisconnect(output);
}
safeDisconnect(this.preampNode);
this.filters.forEach(safeDisconnect);
safeDisconnect(this.outputNode);
// M/S nodes
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);
// Graphic EQ + tail
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;
}
// Insert binaural DSP before EQ
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;
// Connect preamp
if (this.preampNode) {
lastNode.connect(this.preampNode);
lastNode = this.preampNode;
}
if (useMS) {
// === M/S processing path ===
// Encode L/R → M/S
lastNode.connect(this.msSplitter);
this.msSplitter.connect(this.msEncoderMidL, 0); // L → Mid
this.msSplitter.connect(this.msEncoderMidR, 1); // R → Mid
this.msEncoderMidL.connect(this.msMidInput);
this.msEncoderMidR.connect(this.msMidInput); // Mid = (L+R)*0.5
this.msSplitter.connect(this.msEncoderSideL, 0); // L → Side
this.msSplitter.connect(this.msEncoderSideR, 1); // R → Side (-0.5)
this.msEncoderSideL.connect(this.msSideInput);
this.msEncoderSideR.connect(this.msSideInput); // Side = (L-R)*0.5
// Mid filter chain
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);
// Side filter chain
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);
// Decode M/S → L/R
this.midOutputNode.connect(this.msDecoderMidToL);
this.sideOutputNode.connect(this.msDecoderSideToL);
this.msDecoderMidToL.connect(this.msLMix);
this.msDecoderSideToL.connect(this.msLMix); // L = Mid + Side
this.midOutputNode.connect(this.msDecoderMidToR);
this.sideOutputNode.connect(this.msDecoderSideToR);
this.msDecoderMidToR.connect(this.msRMix);
this.msDecoderSideToR.connect(this.msRMix); // R = Mid - Side
this.msLMix.connect(this.msMerger, 0, 0);
this.msRMix.connect(this.msMerger, 0, 1);
this.msMerger.connect(this.msOutputNode);
connectTail(this.msOutputNode);
} else {
// === Normal stereo path ===
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);
}
// Notify visualizers that graph has been reconnected
this._notifyGraphChange();
} catch (e) {
console.warn('[AudioContext] Failed to connect graph:', e);
try {
this.source.connect(this.audioContext.destination);
} catch {
/* ignore */
}
}
if (!this.isInitialized || !this.audioContext) return;
}
/**

View file

@ -294,21 +294,7 @@ export class Player {
const el = this.activeElement;
// Apply to audio element and/or Web Audio graph
const isApple = isIos || isSafari;
if (audioContextManager.isReady() && !isApple) {
// If Web Audio is active, we apply volume there for better compatibility
// Especially on Linux where audio.volume might not affect the Web Audio graph
el.volume = 1.0;
audioContextManager.setVolume(effectiveVolume);
} else {
// Safari bypasses WebAudio for HLS, so we MUST set el.volume directly to reflect ReplayGain
if (audioContextManager.isReady()) {
audioContextManager.setVolume(1.0); // Reset graph gain if it somehow routes
}
el.volume = Math.max(0, Math.min(1, effectiveVolume));
}
el.volume = Math.max(0, Math.min(1, effectiveVolume));
}
applyAudioEffects() {