fix EVERYTHING

This commit is contained in:
uimaxbai 2026-04-17 22:40:15 +01:00
parent 2ba420ff41
commit 274baa2a79
16 changed files with 379 additions and 176 deletions

View file

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

View file

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

View file

@ -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">&#9888;</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">
&times;
</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">

View file

@ -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
View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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) {

View file

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

View file

@ -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',

View file

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

View file

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

View file

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

View file

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