fix EVERYTHING
This commit is contained in:
parent
2ba420ff41
commit
274baa2a79
16 changed files with 379 additions and 176 deletions
|
|
@ -143,7 +143,10 @@ const _cr = [
|
|||
'ZXNzZWw=', // essel
|
||||
'emluZGFnaQ==', // zindagi
|
||||
].map(atob);
|
||||
const _isBlockedCopyright = (c) => !!c && _cr.some((s) => c.toLowerCase().includes(s));
|
||||
const _isBlockedCopyright = (c) => {
|
||||
const text = typeof c === 'string' ? c : c?.text;
|
||||
return !!text && _cr.some((s) => text.toLowerCase().includes(s));
|
||||
};
|
||||
|
||||
export async function onRequest(context) {
|
||||
const { request, params, env } = context;
|
||||
|
|
|
|||
|
|
@ -171,7 +171,10 @@ const _cr = [
|
|||
'ZXNzZWw=', // essel
|
||||
'emluZGFnaQ==', // zindagi
|
||||
].map(atob);
|
||||
const _isBlockedCopyright = (c) => !!c && _cr.some((s) => c.toLowerCase().includes(s));
|
||||
const _isBlockedCopyright = (c) => {
|
||||
const text = typeof c === 'string' ? c : c?.text;
|
||||
return !!text && _cr.some((s) => text.toLowerCase().includes(s));
|
||||
};
|
||||
|
||||
export async function onRequest(context) {
|
||||
const { request, params, env } = context;
|
||||
|
|
|
|||
54
index.html
54
index.html
|
|
@ -2,6 +2,7 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>Monochrome</title>
|
||||
<link rel="canonical" href="https://monochrome.tf/" />
|
||||
|
|
@ -114,8 +115,8 @@
|
|||
</head>
|
||||
|
||||
<body>
|
||||
<audio id="audio-player" crossorigin="anonymous" style="display: none"></audio>
|
||||
<video id="video-player" crossorigin="anonymous" style="display: none"></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">
|
||||
|
|
@ -1678,6 +1679,7 @@
|
|||
<use svg="!lucide/chevron-left.svg" size="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav main">
|
||||
<ul>
|
||||
<li class="nav-item" id="sidebar-nav-home">
|
||||
|
|
@ -1725,7 +1727,18 @@
|
|||
<!-- Pinned should be injected here -->
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-nav-bottom">
|
||||
<div id="server-disruption-banner" class="server-disruption-sidebar" style="display: none">
|
||||
<span class="disruption-icon">⚠</span>
|
||||
<span
|
||||
>Services are currently unstable. <br /><br />For Hi-Res streaming, use Chrome or
|
||||
Safari.</span
|
||||
>
|
||||
<button id="dismiss-disruption-btn" class="disruption-dismiss" title="Dismiss">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<nav class="sidebar-nav bottom">
|
||||
<ul>
|
||||
<li class="nav-item" id="sidebar-nav-about-bottom">
|
||||
|
|
@ -4050,7 +4063,6 @@
|
|||
</div>
|
||||
<select id="streaming-quality-setting">
|
||||
<option value="auto">Auto (Adaptive)</option>
|
||||
<option value="HI_RES_LOSSLESS">Hi-Res Lossless (up to 24-bit/192kHz)</option>
|
||||
<option value="LOSSLESS">Lossless (16-bit)</option>
|
||||
<option value="HIGH">AAC 320kbps</option>
|
||||
<option value="LOW">AAC 96kbps</option>
|
||||
|
|
@ -5539,6 +5551,42 @@
|
|||
|
||||
<div class="settings-tab-content" id="settings-tab-instances">
|
||||
<div class="settings-list">
|
||||
<div class="settings-group">
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label" style="color: #f59e0b">Dev Mode</span>
|
||||
<span class="description"
|
||||
>Route all API requests through a local Tidal HiFi API server. Requires a
|
||||
compatible server running at the specified URL.</span
|
||||
>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="dev-mode-toggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-item" id="dev-mode-url-setting" style="display: none">
|
||||
<div class="info">
|
||||
<span class="label">Dev Mode API URL</span>
|
||||
<span class="description">The URL of your local Tidal HiFi API instance</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="dev-mode-url-input"
|
||||
placeholder="http://127.0.0.1:8000"
|
||||
style="
|
||||
width: 220px;
|
||||
padding: 0.35rem 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--card);
|
||||
color: var(--foreground);
|
||||
font-size: 0.8rem;
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
|
|
|
|||
91
js/HiFi.ts
91
js/HiFi.ts
|
|
@ -1714,20 +1714,39 @@ class HiFiClient {
|
|||
if (!id && !f) throw new ResponseError(400, 'Provide id or f query param');
|
||||
|
||||
if (id) {
|
||||
const artist_url = `https://api.tidal.com/v1/artists/${id}`;
|
||||
const artist_data = await this.#fetchJson<TidalArtistProfile>(
|
||||
const artist_url = `https://openapi.tidal.com/v2/artists/${id}`;
|
||||
const payload = await this.#fetchJson<any>(
|
||||
artist_url,
|
||||
{ countryCode: this.#countryCode },
|
||||
{ countryCode: this.#countryCode, include: 'albums,albums.coverArt,tracks,tracks.albums,biography,profileArt', collapseBy: 'FINGERPRINT' },
|
||||
signal
|
||||
);
|
||||
|
||||
let picture = artist_data.picture;
|
||||
const fallback = artist_data.selectedAlbumCoverFallback;
|
||||
if (!picture && fallback) {
|
||||
artist_data.picture = fallback;
|
||||
picture = fallback;
|
||||
const includedMap = new Map<string, any>();
|
||||
if (Array.isArray(payload?.included)) {
|
||||
for (const item of payload.included) {
|
||||
includedMap.set(`${item.type}:${item.id}`, item);
|
||||
}
|
||||
}
|
||||
|
||||
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 data = payload?.data;
|
||||
const artist_data: any = {
|
||||
id: Number(data?.id || id),
|
||||
name: data?.attributes?.name || '',
|
||||
picture: getPic(data, 'profileArt') || data?.attributes?.selectedAlbumCoverFallback || null,
|
||||
};
|
||||
|
||||
let picture = artist_data.picture;
|
||||
let cover: ArtistCover | null = null;
|
||||
if (picture) {
|
||||
const slug = picture.replace(/-/g, '/');
|
||||
|
|
@ -1738,10 +1757,54 @@ class HiFiClient {
|
|||
};
|
||||
}
|
||||
|
||||
return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, artist: artist_data, cover });
|
||||
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 }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, artist: artist_data, cover, albums: { items: albums }, tracks });
|
||||
}
|
||||
|
||||
// f provided -> gather albums and optionally tracks
|
||||
// fallback to original f logic
|
||||
const albums_url = `https://api.tidal.com/v1/artists/${f}/albums`;
|
||||
const common_params: Params = { countryCode: this.#countryCode, limit: 50 };
|
||||
|
||||
|
|
@ -1834,14 +1897,6 @@ class HiFiClient {
|
|||
|
||||
return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, albums: page_data, tracks });
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the biography text for the given artist ID.
|
||||
*
|
||||
* @param artistId - TIDAL artist ID.
|
||||
* @param signal - Optional {@link AbortSignal} to cancel the request.
|
||||
* @returns A {@link TidalResponse} whose `.json()` resolves to an {@link ArtistBioResponse}.
|
||||
*/
|
||||
async getArtistBiography(artistId: number, signal?: AbortSignal): Promise<TidalResponse<ArtistBioResponse>> {
|
||||
const url = `https://api.tidal.com/v1/artists/${artistId}/bio`;
|
||||
const params = {
|
||||
|
|
|
|||
163
js/api.js
163
js/api.js
|
|
@ -7,7 +7,7 @@ import {
|
|||
getExtensionFromBlob,
|
||||
getTrackDiscNumber,
|
||||
} from './utils.js';
|
||||
import { preferDolbyAtmosSettings, trackDateSettings } from './storage.js';
|
||||
import { preferDolbyAtmosSettings, trackDateSettings, devModeSettings } from './storage.js';
|
||||
import { APICache } from './cache.js';
|
||||
import { DashDownloader } from './dash-downloader.ts';
|
||||
import { HlsDownloader } from './hls-downloader.js';
|
||||
|
|
@ -18,7 +18,7 @@ import { DownloadProgress } from './progressEvents.js';
|
|||
import { resolveDownloadTotalBytes } from './downloadProgressUtils.js';
|
||||
import { readableStreamIterator } from './readableStreamIterator.js';
|
||||
import { HiFiClient, TidalResponse } from './HiFi.ts';
|
||||
import { isIos, isSafari } from './platform-detection.js';
|
||||
import { isIos, isSafari, isChrome } from './platform-detection.js';
|
||||
import {
|
||||
TrackAlbum,
|
||||
EnrichedAlbum,
|
||||
|
|
@ -62,16 +62,23 @@ export class LosslessAPI {
|
|||
|
||||
async fetchWithRetry(relativePath, options = {}) {
|
||||
const type = options.type || 'api';
|
||||
const instanceRoutes = [
|
||||
'/track',
|
||||
'/album/similar',
|
||||
'/artist/similar',
|
||||
'/video',
|
||||
'/recommendations',
|
||||
'/trackManifests',
|
||||
];
|
||||
|
||||
if (window.allTidal == true || !instanceRoutes.some((route) => relativePath.startsWith(route))) {
|
||||
if (devModeSettings.isEnabled()) {
|
||||
const devBaseUrl = devModeSettings.getUrl().replace(/\/+$/, '');
|
||||
const url = devBaseUrl + (relativePath.startsWith('/') ? relativePath : '/' + relativePath);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('[dev-mode]', url);
|
||||
}
|
||||
|
||||
const response = await fetch(url, { signal: options.signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Dev mode request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
if (type !== 'streaming') {
|
||||
try {
|
||||
if (import.meta.env.DEV) {
|
||||
console.log(relativePath);
|
||||
|
|
@ -83,7 +90,7 @@ export class LosslessAPI {
|
|||
throw err;
|
||||
}
|
||||
console.warn(
|
||||
`Direct fetch failed for ${relativePath}. Falling back to configured API instances...`,
|
||||
`Direct Tidal API fetch failed for ${relativePath}. Falling back to configured API instances...`,
|
||||
err
|
||||
);
|
||||
}
|
||||
|
|
@ -456,7 +463,7 @@ export class LosslessAPI {
|
|||
const data = await response.json();
|
||||
|
||||
// Check if backend returned an error or if this looks like individual fallback
|
||||
if (data.error || (!data.tracks && !data.artists && !data.albums && (!data.data || !data.data.tracks))) {
|
||||
if (data.error) {
|
||||
throw new Error('Fallback to individual searches');
|
||||
}
|
||||
|
||||
|
|
@ -1026,11 +1033,7 @@ export class LosslessAPI {
|
|||
if (cached) return cached;
|
||||
}
|
||||
|
||||
const [primaryResponse, contentResponse] = await Promise.all([
|
||||
this.fetchWithRetry(`/artist/?id=${artistId}`),
|
||||
this.fetchWithRetry(`/artist/?f=${artistId}&skip_tracks=true`),
|
||||
]);
|
||||
|
||||
const primaryResponse = await this.fetchWithRetry(`/artist/?id=${artistId}`);
|
||||
const primaryJsonData = await primaryResponse.json();
|
||||
|
||||
// Unwrap data property if it exists, then unwrap artist property if it exists
|
||||
|
|
@ -1045,10 +1048,7 @@ export class LosslessAPI {
|
|||
name: rawArtist.name || 'Unknown Artist',
|
||||
};
|
||||
|
||||
const contentJsonData = await contentResponse.json();
|
||||
// Unwrap data property if it exists
|
||||
const contentData = contentJsonData.data || contentJsonData;
|
||||
const entries = Array.isArray(contentData) ? contentData : [contentData];
|
||||
const entries = [];
|
||||
|
||||
const albumMap = new Map();
|
||||
const trackMap = new Map();
|
||||
|
|
@ -1459,7 +1459,7 @@ export class LosslessAPI {
|
|||
}
|
||||
}
|
||||
|
||||
async getTrack(id, quality = 'HI_RES_LOSSLESS') {
|
||||
async getTrack(id, quality = 'LOSSLESS') {
|
||||
const cacheKey = `${id}_${quality}`;
|
||||
const cached = await this.cache.get('track', cacheKey);
|
||||
if (cached) return cached;
|
||||
|
|
@ -1474,7 +1474,7 @@ export class LosslessAPI {
|
|||
return result;
|
||||
}
|
||||
|
||||
async getStreamUrl(id, quality = 'HI_RES_LOSSLESS', download = false) {
|
||||
async getStreamUrl(id, quality = 'LOSSLESS', download = false) {
|
||||
const cacheKey = `stream_info_${id}_${quality}`;
|
||||
|
||||
if (this.streamCache.has(cacheKey)) {
|
||||
|
|
@ -1483,111 +1483,28 @@ export class LosslessAPI {
|
|||
|
||||
let streamUrl;
|
||||
let manifestRgInfo = null;
|
||||
let isUsingManifestEndpoint = false;
|
||||
|
||||
try {
|
||||
const manifestType = isIos || isSafari ? 'HLS' : 'MPEG_DASH';
|
||||
const isApple = isIos || isSafari;
|
||||
const lookup = await this.getTrack(id, quality);
|
||||
|
||||
let canPlayAtmos = false;
|
||||
try {
|
||||
if (window.MediaSource && typeof window.MediaSource.isTypeSupported === 'function') {
|
||||
canPlayAtmos =
|
||||
MediaSource.isTypeSupported('audio/mp4; codecs="ec-3"') ||
|
||||
MediaSource.isTypeSupported('audio/mp4; codecs="eac3"');
|
||||
}
|
||||
if (!canPlayAtmos && typeof document !== 'undefined') {
|
||||
const a = document.createElement('audio');
|
||||
canPlayAtmos = !!(
|
||||
a.canPlayType('audio/mp4; codecs="ec-3"') || a.canPlayType('audio/mp4; codecs="eac3"')
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Atmos codec probe - intentionally swallowed; canPlayAtmos stays false
|
||||
if (lookup.originalTrackUrl) {
|
||||
streamUrl = lookup.originalTrackUrl;
|
||||
} else {
|
||||
const manifest = lookup.info?.manifest;
|
||||
if (manifest) {
|
||||
streamUrl = this.extractStreamUrlFromManifest(manifest);
|
||||
}
|
||||
|
||||
const paramsArray = [];
|
||||
|
||||
if (quality === 'LOW') {
|
||||
paramsArray.push(['formats', 'HEAACV1']);
|
||||
} else if (quality === 'HIGH') {
|
||||
if (!isApple) paramsArray.push(['formats', 'HEAACV1']);
|
||||
paramsArray.push(['formats', 'AACLC']);
|
||||
} else if (quality === 'LOSSLESS') {
|
||||
// For Safari to not auto-downgrade to AAC, only request FLAC
|
||||
paramsArray.push(['formats', 'HEAACV1']);
|
||||
paramsArray.push(['formats', 'AACLC']);
|
||||
paramsArray.push(['formats', 'FLAC']);
|
||||
} else if (quality === 'HI_RES_LOSSLESS') {
|
||||
paramsArray.push(['formats', 'HEAACV1']);
|
||||
paramsArray.push(['formats', 'AACLC']);
|
||||
paramsArray.push(['formats', 'FLAC_HIRES']);
|
||||
paramsArray.push(['formats', 'FLAC']);
|
||||
} else if (quality === 'DOLBY_ATMOS' && (canPlayAtmos || download)) {
|
||||
paramsArray.push(['formats', 'EAC3_JOC']);
|
||||
} else {
|
||||
// Default fallback or "auto" behavior
|
||||
paramsArray.push(['formats', 'HEAACV1']);
|
||||
paramsArray.push(['formats', 'AACLC']);
|
||||
paramsArray.push(['formats', 'FLAC']);
|
||||
paramsArray.push(['formats', 'FLAC_HIRES']);
|
||||
if (canPlayAtmos || download) {
|
||||
paramsArray.push(['formats', 'EAC3_JOC']);
|
||||
}
|
||||
if (!streamUrl) {
|
||||
throw new Error('Could not resolve stream URL');
|
||||
}
|
||||
|
||||
paramsArray.push(
|
||||
['adaptive', 'true'],
|
||||
['manifestType', manifestType],
|
||||
['uriScheme', 'HTTPS'],
|
||||
['usage', 'PLAYBACK']
|
||||
);
|
||||
|
||||
const params = new URLSearchParams(paramsArray);
|
||||
|
||||
const response = await this.fetchWithRetry(`/trackManifests/?id=${id}&${params.toString()}`, {
|
||||
type: 'streaming',
|
||||
minVersion: '2.7',
|
||||
});
|
||||
const jsonResponse = await response.json();
|
||||
const url = jsonResponse?.data?.data?.attributes?.uri;
|
||||
if (url) {
|
||||
streamUrl = url;
|
||||
manifestRgInfo = {
|
||||
trackReplayGain: jsonResponse?.data?.data?.attributes?.trackAudioNormalizationData?.replayGain,
|
||||
trackPeakAmplitude:
|
||||
jsonResponse?.data?.data?.attributes?.trackAudioNormalizationData?.peakAmplitude,
|
||||
albumReplayGain: jsonResponse?.data?.data?.attributes?.albumAudioNormalizationData?.replayGain,
|
||||
albumPeakAmplitude:
|
||||
jsonResponse?.data?.data?.attributes?.albumAudioNormalizationData?.peakAmplitude,
|
||||
};
|
||||
isUsingManifestEndpoint = true;
|
||||
} else {
|
||||
throw new Error('No URI in trackManifests response');
|
||||
}
|
||||
} catch (_err) {
|
||||
// Fallback to /track endpoint
|
||||
}
|
||||
|
||||
if (!isUsingManifestEndpoint) {
|
||||
const lookup = await this.getTrack(id, quality);
|
||||
|
||||
if (lookup.originalTrackUrl) {
|
||||
streamUrl = lookup.originalTrackUrl;
|
||||
} else {
|
||||
streamUrl = this.extractStreamUrlFromManifest(lookup.info.manifest);
|
||||
if (!streamUrl) {
|
||||
throw new Error('Could not resolve stream URL');
|
||||
}
|
||||
}
|
||||
if (lookup.info) {
|
||||
manifestRgInfo = {
|
||||
trackReplayGain: lookup.info.trackReplayGain || lookup.info.replayGain,
|
||||
trackPeakAmplitude: lookup.info.trackPeakAmplitude || lookup.info.peakAmplitude,
|
||||
albumReplayGain: lookup.info.albumReplayGain,
|
||||
albumPeakAmplitude: lookup.info.albumPeakAmplitude,
|
||||
};
|
||||
}
|
||||
if (lookup.info) {
|
||||
manifestRgInfo = {
|
||||
trackReplayGain: lookup.info.trackReplayGain || lookup.info.replayGain,
|
||||
trackPeakAmplitude: lookup.info.trackPeakAmplitude || lookup.info.peakAmplitude,
|
||||
albumReplayGain: lookup.info.albumReplayGain,
|
||||
albumPeakAmplitude: lookup.info.albumPeakAmplitude,
|
||||
};
|
||||
}
|
||||
|
||||
const result = { url: streamUrl, rgInfo: manifestRgInfo };
|
||||
|
|
|
|||
|
|
@ -491,7 +491,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
}
|
||||
}
|
||||
|
||||
const currentQuality = localStorage.getItem('playback-quality') || 'HI_RES_LOSSLESS';
|
||||
const currentQuality = localStorage.getItem('playback-quality') || 'LOSSLESS';
|
||||
await Player.initialize(audioPlayer, MusicAPI.instance, currentQuality);
|
||||
|
||||
// Initialize tracker
|
||||
|
|
|
|||
|
|
@ -563,12 +563,12 @@ class CommandPalette {
|
|||
action: () => this.setQuality('LOSSLESS'),
|
||||
},
|
||||
{
|
||||
id: 'quality-hires',
|
||||
id: 'quality-lossless',
|
||||
group: 'Audio',
|
||||
icon: 'sliders',
|
||||
label: 'Quality: Hi-Res',
|
||||
keywords: ['quality', 'hires', 'hi-res', 'master', 'mqa', 'streaming'],
|
||||
action: () => this.setQuality('HI_RES_LOSSLESS'),
|
||||
label: 'Quality: Lossless',
|
||||
keywords: ['quality', 'lossless', 'flac', 'streaming'],
|
||||
action: () => this.setQuality('LOSSLESS'),
|
||||
},
|
||||
{
|
||||
id: 'sleep-15',
|
||||
|
|
@ -1206,7 +1206,7 @@ class CommandPalette {
|
|||
|
||||
if (Player.instance) {
|
||||
// Set fallback API quality (Auto maps back to Hi-Res)
|
||||
const apiQuality = quality === 'auto' ? 'HI_RES_LOSSLESS' : quality;
|
||||
const apiQuality = quality === 'auto' ? 'LOSSLESS' : quality;
|
||||
Player.instance.setQuality(apiQuality);
|
||||
localStorage.setItem('playback-quality', apiQuality);
|
||||
|
||||
|
|
@ -1220,7 +1220,7 @@ class CommandPalette {
|
|||
|
||||
const { downloadQualitySettings } = await import('./storage.js');
|
||||
// Do not pass auto to download quality, resolve it to original fallback
|
||||
const dlQuality = quality === 'auto' ? 'HI_RES_LOSSLESS' : quality;
|
||||
const dlQuality = quality === 'auto' ? 'LOSSLESS' : quality;
|
||||
downloadQualitySettings.setQuality(dlQuality);
|
||||
const downloadSelect = document.getElementById('download-quality-setting');
|
||||
if (downloadSelect) downloadSelect.value = dlQuality;
|
||||
|
|
|
|||
|
|
@ -8,5 +8,7 @@ const _cr = [
|
|||
'emluZGFnaQ==',
|
||||
].map(atob);
|
||||
|
||||
export const isBlockedCopyright = (c: string | null | undefined): boolean =>
|
||||
!!c && _cr.some((s) => c.toLowerCase().includes(s));
|
||||
export const isBlockedCopyright = (c: string | { text?: string } | null | undefined): boolean => {
|
||||
const text = typeof c === 'string' ? c : c?.text;
|
||||
return !!text && _cr.some((s) => text.toLowerCase().includes(s));
|
||||
};
|
||||
|
|
|
|||
|
|
@ -15,3 +15,6 @@ export const isSafari =
|
|||
!lowerCaseOriginalUserAgent.includes('chrome') &&
|
||||
!lowerCaseOriginalUserAgent.includes('crios') &&
|
||||
!lowerCaseOriginalUserAgent.includes('android');
|
||||
|
||||
/** If the browser is Chrome. */
|
||||
export const isChrome = lowerCaseOriginalUserAgent.includes('chrome') || lowerCaseOriginalUserAgent.includes('crios');
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ export class Player {
|
|||
}
|
||||
|
||||
/** @private */
|
||||
constructor(audioElement, api, quality = 'HI_RES_LOSSLESS') {
|
||||
constructor(audioElement, api, quality = 'LOSSLESS') {
|
||||
this.audio = audioElement;
|
||||
this.video = document.getElementById('video-player');
|
||||
this.api = api;
|
||||
|
|
@ -664,17 +664,12 @@ export class Player {
|
|||
);
|
||||
}
|
||||
} else {
|
||||
// For static files (FLAC, MP3), standard fetch of the first ~5MB completely primes the cache.
|
||||
// For static files (FLAC, MP3), the audio element completely primes the cache.
|
||||
const preloader = new Audio();
|
||||
preloader.preload = 'auto';
|
||||
preloader.muted = true;
|
||||
preloader.src = streamUrl;
|
||||
streamInfo.preloader = preloader; // Hold reference
|
||||
|
||||
fetch(streamUrl, {
|
||||
headers: { Range: 'bytes=0-5242880' },
|
||||
signal: this.preloadAbortController.signal,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ import {
|
|||
fullscreenCoverVanillaTiltSettings,
|
||||
fullscreenCoverTiltDistanceSettings,
|
||||
fullscreenCoverTiltSpeedSettings,
|
||||
devModeSettings,
|
||||
serverDisruptionSettings,
|
||||
} from './storage.js';
|
||||
import { audioContextManager, getPresetsForBandCount } from './audio-context.js';
|
||||
import { calculateBiquadResponse, interpolate, getNormalizationOffset, runAutoEqAlgorithm } from './autoeq-engine.js';
|
||||
|
|
@ -76,6 +78,51 @@ export async function initializeSettings(scrobbler, player, api, ui) {
|
|||
// Initialize account system UI & Settings
|
||||
authManager.updateUI(authManager.user);
|
||||
|
||||
// ========================================
|
||||
// Dev Mode
|
||||
// ========================================
|
||||
const devModeToggle = document.getElementById('dev-mode-toggle');
|
||||
const devModeUrlSetting = document.getElementById('dev-mode-url-setting');
|
||||
const devModeUrlInput = document.getElementById('dev-mode-url-input');
|
||||
|
||||
function updateDevModeUI() {
|
||||
if (devModeToggle) devModeToggle.checked = devModeSettings.isEnabled();
|
||||
if (devModeUrlSetting) devModeUrlSetting.style.display = devModeSettings.isEnabled() ? '' : 'none';
|
||||
if (devModeUrlInput) devModeUrlInput.value = devModeSettings.getUrl();
|
||||
}
|
||||
|
||||
updateDevModeUI();
|
||||
|
||||
if (devModeToggle) {
|
||||
devModeToggle.addEventListener('change', (e) => {
|
||||
devModeSettings.setEnabled(e.target.checked);
|
||||
updateDevModeUI();
|
||||
});
|
||||
}
|
||||
|
||||
if (devModeUrlInput) {
|
||||
devModeUrlInput.addEventListener('change', (e) => {
|
||||
devModeSettings.setUrl(e.target.value.trim());
|
||||
});
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Server Disruption Banner
|
||||
// ========================================
|
||||
const disruptionBanner = document.getElementById('server-disruption-banner');
|
||||
const dismissDisruptionBtn = document.getElementById('dismiss-disruption-btn');
|
||||
|
||||
if (disruptionBanner && !serverDisruptionSettings.isDismissed()) {
|
||||
disruptionBanner.style.display = 'flex';
|
||||
}
|
||||
|
||||
if (dismissDisruptionBtn) {
|
||||
dismissDisruptionBtn.addEventListener('click', () => {
|
||||
serverDisruptionSettings.dismiss();
|
||||
if (disruptionBanner) disruptionBanner.style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// Email Auth UI Logic
|
||||
const toggleEmailBtn = document.getElementById('toggle-email-auth-btn');
|
||||
const authModalCloseBtn = document.getElementById('email-auth-modal-close');
|
||||
|
|
@ -806,7 +853,7 @@ export async function initializeSettings(scrobbler, player, api, ui) {
|
|||
|
||||
// Apply initially
|
||||
if (player.forceQuality) player.forceQuality(streamingQualitySetting.value);
|
||||
const apiQuality = streamingQualitySetting.value === 'auto' ? 'HI_RES_LOSSLESS' : streamingQualitySetting.value;
|
||||
const apiQuality = streamingQualitySetting.value === 'auto' ? 'LOSSLESS' : streamingQualitySetting.value;
|
||||
player.setQuality(localStorage.getItem('playback-quality') || apiQuality);
|
||||
|
||||
streamingQualitySetting.addEventListener('change', (e) => {
|
||||
|
|
@ -817,7 +864,7 @@ export async function initializeSettings(scrobbler, player, api, ui) {
|
|||
if (player.forceQuality) player.forceQuality(val);
|
||||
|
||||
// Set fallback API quality
|
||||
const newApiQuality = val === 'auto' ? 'HI_RES_LOSSLESS' : val;
|
||||
const newApiQuality = val === 'auto' ? 'LOSSLESS' : val;
|
||||
player.setQuality(newApiQuality);
|
||||
localStorage.setItem('playback-quality', newApiQuality);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3121,6 +3121,55 @@ export const modalSettings = {
|
|||
},
|
||||
};
|
||||
|
||||
export const devModeSettings = {
|
||||
STORAGE_KEY: 'dev-mode-enabled',
|
||||
URL_KEY: 'dev-mode-url',
|
||||
|
||||
isEnabled() {
|
||||
try {
|
||||
return localStorage.getItem(this.STORAGE_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
setEnabled(enabled) {
|
||||
localStorage.setItem(this.STORAGE_KEY, enabled ? 'true' : 'false');
|
||||
},
|
||||
|
||||
getUrl() {
|
||||
try {
|
||||
return localStorage.getItem(this.URL_KEY) || 'http://127.0.0.1:8000';
|
||||
} catch {
|
||||
return 'http://127.0.0.1:8000';
|
||||
}
|
||||
},
|
||||
|
||||
setUrl(url) {
|
||||
localStorage.setItem(this.URL_KEY, url);
|
||||
},
|
||||
};
|
||||
|
||||
export const serverDisruptionSettings = {
|
||||
STORAGE_KEY: 'server-disruption-dismissed',
|
||||
|
||||
isDismissed() {
|
||||
try {
|
||||
return localStorage.getItem(this.STORAGE_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
dismiss() {
|
||||
localStorage.setItem(this.STORAGE_KEY, 'true');
|
||||
},
|
||||
|
||||
reset() {
|
||||
localStorage.removeItem(this.STORAGE_KEY);
|
||||
},
|
||||
};
|
||||
|
||||
export const contentBlockingSettings = {
|
||||
BLOCKED_ARTISTS_KEY: 'blocked-artists',
|
||||
BLOCKED_TRACKS_KEY: 'blocked-tracks',
|
||||
|
|
|
|||
14
js/ui.js
14
js/ui.js
|
|
@ -517,13 +517,14 @@ export class UIRenderer {
|
|||
isUnavailable ? 'unavailable' : '',
|
||||
isBlocked ? 'blocked' : '',
|
||||
showRowLike ? 'track-item--inline-like' : '',
|
||||
this.currentPage === 'search' ? 'no-duration' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return `
|
||||
<div class="${classList}"
|
||||
data-track-id="${track.id}"
|
||||
<div class="${classList}"
|
||||
data-track-id="${track.id}"
|
||||
${isVideo ? 'data-type="video"' : 'data-type="track"'}
|
||||
${track.isLocal ? 'data-is-local="true"' : ''}
|
||||
${isUnavailable ? 'title="This track is currently unavailable"' : ''}
|
||||
|
|
@ -892,8 +893,9 @@ export class UIRenderer {
|
|||
}
|
||||
|
||||
createSkeletonTrack(showCover = false) {
|
||||
const noDurationClass = this.currentPage === 'search' ? ' no-duration' : '';
|
||||
return `
|
||||
<div class="skeleton-track">
|
||||
<div class="skeleton-track${noDurationClass}">
|
||||
${showCover ? '<div class="skeleton skeleton-track-cover"></div>' : '<div class="skeleton skeleton-track-number"></div>'}
|
||||
<div class="skeleton-track-info">
|
||||
<div class="skeleton-track-details">
|
||||
|
|
@ -4061,9 +4063,9 @@ export class UIRenderer {
|
|||
const quote = decodeHtml(review.text || review.quote || 'No review text available.');
|
||||
|
||||
reviewdiv.innerHTML = `
|
||||
<img src="${review.image}" width="50" height="50" style="border-radius: 8px; object-fit: cover; background: var(--highlight);"
|
||||
onerror="this.src='images/monochrome-logo.svg'; this.onerror=null;"
|
||||
loading="lazy"
|
||||
<img src="${review.image}" width="50" height="50" style="border-radius: 8px; object-fit: cover; background: var(--highlight);"
|
||||
onerror="this.src='images/monochrome-logo.svg'; this.onerror=null;"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer">
|
||||
<div style="flex: 1;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.25rem;">
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { modernSettings } from './ModernSettings.js';
|
|||
import { SVG_ATMOS } from './icons.js';
|
||||
import { qualityBadgeSettings, coverArtSizeSettings, trackDateSettings } from './storage.js';
|
||||
|
||||
export const QUALITY = 'HI_RES_LOSSLESS';
|
||||
export const QUALITY = 'LOSSLESS';
|
||||
|
||||
export const REPEAT_MODE = {
|
||||
OFF: 0,
|
||||
|
|
@ -339,6 +339,8 @@ export const deriveTrackQuality = (track) => {
|
|||
const candidates = [
|
||||
deriveQualityFromTags(track.mediaMetadata?.tags),
|
||||
deriveQualityFromTags(track.album?.mediaMetadata?.tags),
|
||||
deriveQualityFromTags(track.mediaTags),
|
||||
deriveQualityFromTags(track.album?.mediaTags),
|
||||
normalizeQualityToken(track.audioQuality),
|
||||
];
|
||||
|
||||
|
|
|
|||
58
styles.css
58
styles.css
|
|
@ -1043,6 +1043,44 @@ ul {
|
|||
padding-bottom: 160px !important;
|
||||
}
|
||||
|
||||
.server-disruption-sidebar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.375rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
margin-bottom: 1rem;
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
border: 1px solid rgba(245, 158, 11, 0.25);
|
||||
border-radius: var(--radius-md);
|
||||
color: #f59e0b;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.server-disruption-sidebar .disruption-icon {
|
||||
font-size: 0.8rem;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.server-disruption-sidebar .disruption-dismiss {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #f59e0b;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
padding: 0 0.125rem;
|
||||
line-height: 1;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.server-disruption-sidebar .disruption-dismiss:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#page-background {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
|
@ -2221,6 +2259,18 @@ input[type='search']::-webkit-search-cancel-button {
|
|||
position: relative;
|
||||
}
|
||||
|
||||
.track-item.no-duration {
|
||||
grid-template-columns: 40px 1fr auto;
|
||||
}
|
||||
|
||||
.track-item.no-duration .track-item-duration {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.track-item.no-duration.track-item--inline-like {
|
||||
grid-template-columns: 40px 1fr auto auto;
|
||||
}
|
||||
|
||||
.track-item:hover {
|
||||
background-color: var(--secondary);
|
||||
transform: scale(1.005);
|
||||
|
|
@ -4879,6 +4929,14 @@ input:checked + .slider::before {
|
|||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
}
|
||||
|
||||
.skeleton-track.no-duration {
|
||||
grid-template-columns: 40px 1fr 40px;
|
||||
}
|
||||
|
||||
.skeleton-track.no-duration .skeleton-track-duration {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.skeleton-track-number {
|
||||
width: 24px;
|
||||
height: 20px;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,27 @@
|
|||
import { HiFiClient } from './js/HiFi.ts';
|
||||
import { LosslessAPI } from './js/api.js';
|
||||
|
||||
// mock out modules to make LosslessAPI load in bun
|
||||
import { mock } from 'bun:test';
|
||||
mock.module('./js/icons.ts', () => ({}));
|
||||
mock.module('./js/settings.js', () => ({ devModeSettings: { isEnabled: () => false }, syncManager: {}, musicProviderSettings: {}, audioSettings: {}, apiSettings: {} }));
|
||||
|
||||
globalThis.localStorage = { getItem: () => null, setItem: () => {}, removeItem: () => {} };
|
||||
globalThis.window = { matchMedia: () => ({ matches: false }) };
|
||||
|
||||
async function test() {
|
||||
const client = new HiFiClient();
|
||||
const res = await client.query('/search/?q=alskdjfalksjdfld&limit=5');
|
||||
const json = await res.json();
|
||||
console.log(JSON.stringify(json.data || {}));
|
||||
await HiFiClient.initialize();
|
||||
const api = new LosslessAPI({ getInstances: () => [] });
|
||||
|
||||
// mock cache
|
||||
api.cache = { get: () => null, set: () => {} };
|
||||
|
||||
api.fetchWithRetry = async function(relativePath, options) {
|
||||
console.log("fetchWithRetry called:", relativePath);
|
||||
return HiFiClient.instance.query(relativePath);
|
||||
};
|
||||
|
||||
const res = await api.search('coldplay');
|
||||
console.log("Returned tracks:", res.tracks?.items?.length);
|
||||
}
|
||||
void test();
|
||||
test().catch(console.error);
|
||||
|
|
|
|||
Loading…
Reference in a new issue