diff --git a/functions/album/[id].js b/functions/album/[id].js index 9c815bb..ccdd72f 100644 --- a/functions/album/[id].js +++ b/functions/album/[id].js @@ -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; diff --git a/functions/track/[id].js b/functions/track/[id].js index e734df2..68162fe 100644 --- a/functions/track/[id].js +++ b/functions/track/[id].js @@ -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; diff --git a/index.html b/index.html index be50f82..0d46417 100644 --- a/index.html +++ b/index.html @@ -2,6 +2,7 @@ + Monochrome @@ -114,8 +115,8 @@ - - + +
+ + + + + + + +
diff --git a/js/HiFi.ts b/js/HiFi.ts index eb51584..8278320 100644 --- a/js/HiFi.ts +++ b/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( + const artist_url = `https://openapi.tidal.com/v2/artists/${id}`; + const payload = await this.#fetchJson( 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(); + 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> { const url = `https://api.tidal.com/v1/artists/${artistId}/bio`; const params = { diff --git a/js/api.js b/js/api.js index 74dcea7..09390ed 100644 --- a/js/api.js +++ b/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 }; diff --git a/js/app.js b/js/app.js index 7b12f51..3b25c86 100644 --- a/js/app.js +++ b/js/app.js @@ -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 diff --git a/js/commandPalette.js b/js/commandPalette.js index ca095db..e0719bd 100644 --- a/js/commandPalette.js +++ b/js/commandPalette.js @@ -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; diff --git a/js/content-filter.ts b/js/content-filter.ts index c6ec6c0..8c21f02 100644 --- a/js/content-filter.ts +++ b/js/content-filter.ts @@ -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)); +}; diff --git a/js/platform-detection.ts b/js/platform-detection.ts index a44ac38..4521c13 100644 --- a/js/platform-detection.ts +++ b/js/platform-detection.ts @@ -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'); diff --git a/js/player.js b/js/player.js index ef09ae2..6b72fa8 100644 --- a/js/player.js +++ b/js/player.js @@ -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) { diff --git a/js/settings.js b/js/settings.js index a691e21..0f5ee2d 100644 --- a/js/settings.js +++ b/js/settings.js @@ -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); }); diff --git a/js/storage.js b/js/storage.js index 185c351..9841afb 100644 --- a/js/storage.js +++ b/js/storage.js @@ -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', diff --git a/js/ui.js b/js/ui.js index bc37fdd..a3680b9 100644 --- a/js/ui.js +++ b/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 ` -
+
${showCover ? '
' : '
'}
@@ -4061,9 +4063,9 @@ export class UIRenderer { const quote = decodeHtml(review.text || review.quote || 'No review text available.'); reviewdiv.innerHTML = ` -
diff --git a/js/utils.js b/js/utils.js index 25bceba..256367a 100644 --- a/js/utils.js +++ b/js/utils.js @@ -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), ]; diff --git a/styles.css b/styles.css index 99711b8..1bd604e 100644 --- a/styles.css +++ b/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; diff --git a/test-search.js b/test-search.js index f44e1ac..39c67c4 100644 --- a/test-search.js +++ b/test-search.js @@ -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);