//js/utils.js import { qualityBadgeSettings, coverArtSizeSettings, trackDateSettings } from './storage.js'; export const QUALITY = 'HI_RES_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 getTrackYearDisplay = (track) => { const useAlbumYear = trackDateSettings.useAlbumYear(); const releaseDate = useAlbumYear ? track?.album?.releaseDate || track?.streamStartDate : track?.streamStartDate || track?.album?.releaseDate; if (!releaseDate) return ''; const date = new Date(releaseDate); return isNaN(date.getTime()) ? '' : ` • ${date.getFullYear()}`; }; 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(); }; /** * Detects actual audio format from blob signature * @param {Blob} blob - Audio blob to analyze * @returns {Promise} - Extension: 'flac', 'm4a', or fallback based on mime */ export const getExtensionFromBlob = async (blob) => { const buffer = await blob.slice(0, 12).arrayBuffer(); const view = new DataView(buffer); // Check for FLAC signature: "fLaC" (0x66 0x4C 0x61 0x43) if ( view.byteLength >= 4 && view.getUint8(0) === 0x66 && // f view.getUint8(1) === 0x4c && // L view.getUint8(2) === 0x61 && // a view.getUint8(3) === 0x43 // C ) { return 'flac'; } // Check for MP4/M4A signature: "ftyp" at offset 4 if ( view.byteLength >= 8 && view.getUint8(4) === 0x66 && // f view.getUint8(5) === 0x74 && // t view.getUint8(6) === 0x79 && // y view.getUint8(7) === 0x70 // p ) { return 'm4a'; } // Fallback to MIME type const mime = blob.type; if (mime === 'audio/flac') return 'flac'; if (mime === 'audio/mp4' || mime === 'audio/x-m4a') return 'm4a'; // Default fallback return 'flac'; }; export const getExtensionForQuality = (quality) => { switch (quality) { case 'LOW': case 'HIGH': return 'm4a'; default: return 'flac'; } }; export const buildTrackFilename = (track, quality, extension = null) => { const template = localStorage.getItem('filename-template') || '{trackNumber} - {artist} - {title}'; const ext = 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) + '.' + ext; }; 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 'HD'; } 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 isTrackUnavailable = (track) => { if (!track) return true; if (track.isLocal) return false; // AllowStreaming false or StreamReady false usually mean unavailable // title === 'Unavailable' is also a strong indicator from the user's example return track.allowStreaming === false || track.streamReady === false || track.title === 'Unavailable'; }; 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) => { // Check if this is a tracker/unreleased track const isTracker = track.isTracker || (track.id && String(track.id).startsWith('tracker-')); if (isTracker && track.trackerInfo?.sheetId) { // For tracker tracks, link to the tracker artist page return `${artist.name}`; } // For normal tracks, use the artist ID return `${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(); function resizeImageBlob(blob, size) { return new Promise((resolve, reject) => { const img = new Image(); const url = URL.createObjectURL(blob); img.onload = () => { URL.revokeObjectURL(url); const canvas = document.createElement('canvas'); canvas.width = size; canvas.height = size; const ctx = canvas.getContext('2d'); ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; ctx.drawImage(img, 0, 0, size, size); canvas.toBlob( (resizedBlob) => { if (resizedBlob) resolve(resizedBlob); else reject(new Error('Canvas toBlob failed')); }, blob.type || 'image/jpeg', 0.9 ); }; img.onerror = (e) => { URL.revokeObjectURL(url); reject(e); }; img.src = url; }); } /** * Fetches and caches cover art as a Blob */ export async function getCoverBlob(api, coverId) { if (!coverId) return null; let sizeStr = coverArtSizeSettings.getSize(); if (sizeStr.includes('x')) { sizeStr = sizeStr.split('x')[0]; } let requestedSize = parseInt(sizeStr, 10); if (isNaN(requestedSize) || requestedSize <= 0) requestedSize = 1280; const cacheKey = `${coverId}-${requestedSize}`; if (coverCache.has(cacheKey)) return coverCache.get(cacheKey); // Tidal seems to only support these soooo const supportedSizes = [80, 160, 320, 640, 1280]; let fetchSize = 1280; const bestSize = supportedSizes.find((s) => s >= requestedSize); if (bestSize) { fetchSize = bestSize; } 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; }; let blob = null; try { const url = api.getCoverUrl(coverId, fetchSize.toString()); // Try direct fetch first const response = await fetch(url); if (response.ok) { blob = await response.blob(); } else { // If direct fetch fails (e.g. 404 from SW due to CORS), try proxy blob = await fetchWithProxy(url); } } catch { // Network error (CORS rejection not handled by SW), try proxy const url = api.getCoverUrl(coverId, fetchSize.toString()); blob = await fetchWithProxy(url); } if (blob) { if (fetchSize !== requestedSize) { try { blob = await resizeImageBlob(blob, requestedSize); } catch (e) { console.warn('Failed to resize cover art, using original size:', e); } } coverCache.set(cacheKey, blob); return blob; } return null; } /** * Positions a menu element relative to a point or an anchor rectangle, * ensuring it stays within the viewport and becomes scrollable if too tall. * @param {HTMLElement} menu - The menu element to position * @param {number} x - X coordinate (clientX) * @param {number} y - Y coordinate (clientY) * @param {DOMRect} [anchorRect] - Optional anchor element rectangle */ export function positionMenu(menu, x, y, anchorRect = null) { // Temporarily show to measure dimensions menu.style.visibility = 'hidden'; menu.style.display = 'block'; menu.style.maxHeight = ''; menu.style.overflowY = ''; const menuWidth = menu.offsetWidth; const menuHeight = menu.offsetHeight; const windowWidth = window.innerWidth; const windowHeight = window.innerHeight; let left = x; let top = y; if (anchorRect) { // Adjust horizontal position if it overflows right if (left + menuWidth > windowWidth - 10) { left = Math.max(10, anchorRect.right - menuWidth); } // Adjust vertical position if it overflows bottom if (top + menuHeight > windowHeight - 10) { top = Math.max(10, anchorRect.top - menuHeight - 5); } } else { // Adjust horizontal position if it overflows right if (left + menuWidth > windowWidth - 10) { left = Math.max(10, windowWidth - menuWidth - 10); } // Adjust vertical position if it overflows bottom if (top + menuHeight > windowHeight - 10) { top = Math.max(10, y - menuHeight); } } // Final checks to ensure it's not off-screen at the top or left if (left < 10) left = 10; if (top < 10) top = 10; // If it's still too tall for the viewport, make it scrollable // We measure again because max-height might be needed const currentMenuHeight = menu.offsetHeight; if (top + currentMenuHeight > windowHeight - 10) { menu.style.maxHeight = `${windowHeight - top - 10}px`; menu.style.overflowY = 'auto'; } menu.style.top = `${top}px`; menu.style.left = `${left}px`; menu.style.visibility = 'visible'; }