fix streaming for now
This commit is contained in:
parent
6ddb411b94
commit
c2b3f7312e
5 changed files with 290 additions and 357 deletions
|
|
@ -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">
|
||||
|
|
|
|||
174
js/HiFi.ts
174
js/HiFi.ts
|
|
@ -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,68 @@ 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) });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
225
js/api.js
225
js/api.js
|
|
@ -62,6 +62,105 @@ 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 +177,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 +497,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 +536,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 +1163,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 +1284,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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
16
js/player.js
16
js/player.js
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Reference in a new issue