//js/utils.js import { qualityBadgeSettings } from './storage.js'; export const QUALITY = 'LOSSLESS'; export const REPEAT_MODE = { OFF: 0, ALL: 1, ONE: 2, }; export const AUDIO_QUALITIES = { HI_RES_LOSSLESS: 'HI_RES_LOSSLESS', LOSSLESS: 'LOSSLESS', HIGH: 'HIGH', LOW: 'LOW', }; export const QUALITY_PRIORITY = ['HI_RES_LOSSLESS', 'LOSSLESS', 'HIGH', 'LOW']; export const QUALITY_TOKENS = { HI_RES_LOSSLESS: [ 'HI_RES_LOSSLESS', 'HIRES_LOSSLESS', 'HIRESLOSSLESS', 'HIFI_PLUS', 'HI_RES_FLAC', 'HI_RES', 'HIRES', 'MASTER', 'MASTER_QUALITY', 'MQA', ], LOSSLESS: ['LOSSLESS', 'HIFI'], HIGH: ['HIGH', 'HIGH_QUALITY'], LOW: ['LOW', 'LOW_QUALITY'], }; export const RATE_LIMIT_ERROR_MESSAGE = 'Too Many Requests. Please wait a moment and try again.'; export const SVG_PLAY = ''; export const SVG_PAUSE = ''; export const SVG_VOLUME = ''; export const SVG_MUTE = ''; export const SVG_DOWNLOAD = ''; export const SVG_MENU = ''; export const SVG_HEART = ''; export const SVG_CLOSE = ''; export const SVG_BIN = ''; export const SVG_MIX = ''; export const formatTime = (seconds) => { if (isNaN(seconds)) return '0:00'; const m = Math.floor(seconds / 60); const s = Math.floor(seconds % 60); return `${m}:${String(s).padStart(2, '0')}`; }; export const createPlaceholder = (text, isLoading = false) => { return `
${text}
`; }; export const trackDataStore = new WeakMap(); export const sanitizeForFilename = (value) => { if (!value) return 'Unknown'; return value .replace(/[\\/:*?"<>|]/g, '_') .replace(/\s+/g, ' ') .trim(); }; export const getExtensionForQuality = (quality) => { switch (quality) { case 'LOW': case 'HIGH': return 'm4a'; default: return 'flac'; } }; export const buildTrackFilename = (track, quality) => { const template = localStorage.getItem('filename-template') || '{trackNumber} - {artist} - {title}'; const extension = getExtensionForQuality(quality); const artistName = track.artist?.name || track.artists?.[0]?.name || 'Unknown Artist'; const data = { trackNumber: track.trackNumber, artist: artistName, title: getTrackTitle(track), album: track.album?.title, }; return formatTemplate(template, data) + '.' + extension; }; const sanitizeToken = (value) => { if (!value) return ''; return value .trim() .toUpperCase() .replace(/[^A-Z0-9]+/g, '_'); }; export const normalizeQualityToken = (value) => { if (!value) return null; const token = sanitizeToken(value); for (const [quality, aliases] of Object.entries(QUALITY_TOKENS)) { if (aliases.includes(token)) { return quality; } } return null; }; export const createQualityBadgeHTML = (track) => { if (!qualityBadgeSettings.isEnabled()) return ''; const quality = deriveTrackQuality(track); if (quality === 'HI_RES_LOSSLESS') { return 'HR'; } return ''; }; export const deriveQualityFromTags = (rawTags) => { if (!Array.isArray(rawTags)) return null; const candidates = []; for (const tag of rawTags) { if (typeof tag !== 'string') continue; const normalized = normalizeQualityToken(tag); if (normalized && !candidates.includes(normalized)) { candidates.push(normalized); } } return pickBestQuality(candidates); }; export const pickBestQuality = (candidates) => { let best = null; let bestRank = Infinity; for (const candidate of candidates) { if (!candidate) continue; const rank = QUALITY_PRIORITY.indexOf(candidate); const currentRank = rank === -1 ? Infinity : rank; if (currentRank < bestRank) { best = candidate; bestRank = currentRank; } } return best; }; export const deriveTrackQuality = (track) => { if (!track) return null; const candidates = [ deriveQualityFromTags(track.mediaMetadata?.tags), deriveQualityFromTags(track.album?.mediaMetadata?.tags), normalizeQualityToken(track.audioQuality), ]; return pickBestQuality(candidates); }; export const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); export const hasExplicitContent = (item) => { return item?.explicit === true || item?.explicitLyrics === true; }; export const debounce = (func, wait) => { let timeout; return function executedFunction(...args) { const later = () => { clearTimeout(timeout); func(...args); }; clearTimeout(timeout); timeout = setTimeout(later, wait); }; }; export const escapeHtml = (unsafe) => { if (typeof unsafe !== 'string') return unsafe; return unsafe .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); }; export const getTrackTitle = (track, { fallback = 'Unknown Title' } = {}) => { if (!track?.title) return fallback; return track?.version ? `${track.title} (${track.version})` : track.title; }; export const getTrackArtists = (track = {}, { fallback = 'Unknown Artist' } = {}) => { if (track?.artists?.length) { return track.artists.map((artist) => artist?.name).join(', '); } return fallback; }; export const getTrackArtistsHTML = (track = {}, { fallback = 'Unknown Artist' } = {}) => { if (track?.artists?.length) { return track.artists .map((artist) => `${artist.name}`) .join(', '); } return fallback; }; export const formatTemplate = (template, data) => { let result = template; result = result.replace(/\{trackNumber\}/g, data.trackNumber ? String(data.trackNumber).padStart(2, '0') : '00'); result = result.replace(/\{artist\}/g, sanitizeForFilename(data.artist || 'Unknown Artist')); result = result.replace(/\{title\}/g, sanitizeForFilename(data.title || 'Unknown Title')); result = result.replace(/\{album\}/g, sanitizeForFilename(data.album || 'Unknown Album')); result = result.replace(/\{albumArtist\}/g, sanitizeForFilename(data.albumArtist || 'Unknown Artist')); result = result.replace(/\{albumTitle\}/g, sanitizeForFilename(data.albumTitle || 'Unknown Album')); result = result.replace(/\{year\}/g, data.year || 'Unknown'); return result; }; export const calculateTotalDuration = (tracks) => { if (!Array.isArray(tracks) || tracks.length === 0) return 0; return tracks.reduce((total, track) => total + (track.duration || 0), 0); }; export const formatDuration = (seconds) => { if (!seconds || isNaN(seconds)) return '0 min'; const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); if (hours > 0) { return `${hours} hr ${minutes} min`; } return `${minutes} min`; }; const coverCache = new Map(); /** * Fetches and caches cover art as a Blob */ export async function getCoverBlob(api, coverId) { if (!coverId) return null; if (coverCache.has(coverId)) return coverCache.get(coverId); const fetchWithProxy = async (url) => { try { const proxyUrl = `https://corsproxy.io/?${encodeURIComponent(url)}`; const response = await fetch(proxyUrl); if (response.ok) return await response.blob(); } catch (e) { console.warn('Proxy fetch failed:', e); } return null; }; try { const url = api.getCoverUrl(coverId, '1280'); // Try direct fetch first const response = await fetch(url); if (response.ok) { const blob = await response.blob(); coverCache.set(coverId, blob); return blob; } else { // If direct fetch fails (e.g. 404 from SW due to CORS), try proxy const blob = await fetchWithProxy(url); if (blob) { coverCache.set(coverId, blob); return blob; } } } catch { // Network error (CORS rejection not handled by SW), try proxy const url = api.getCoverUrl(coverId, '1280'); const blob = await fetchWithProxy(url); if (blob) { coverCache.set(coverId, blob); return blob; } } return null; }